From 6ac2e1e34a9d12d6a878c188cc08fe3ffefcb4a5 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 15:01:33 +0200 Subject: [PATCH 01/24] 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 26540b058c65f5445b6aca24fa737a277cccceaf Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 19:58:14 +0200 Subject: [PATCH 02/24] docs: add dynamic live activities rendering ADR --- docs/adr/0001-dynamic-live-activities.md | 117 +++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/adr/0001-dynamic-live-activities.md diff --git a/docs/adr/0001-dynamic-live-activities.md b/docs/adr/0001-dynamic-live-activities.md new file mode 100644 index 00000000..7085ccb3 --- /dev/null +++ b/docs/adr/0001-dynamic-live-activities.md @@ -0,0 +1,117 @@ +# ADR 0001: Dynamic Live Activities rendering + +## Introduction + +Live Activities currently use a server-rendered engine that serializes the complete UI into every update payload. The new dynamic engine will bundle Live Activity definitions with the app and send only their props in updates, reducing payload size and moving rendering onto the device. + +Unlike Home Screen widgets, Live Activities can be started and updated through ActivityKit push notifications. Pushes may reach older app releases or releases that do not contain the requested definition. The rollout must therefore support both engines and reject unsupported dynamic activities without crashing the app or widget extension. + +## Context + +The existing `VoltraAttributes` and `VoltraWidget` configuration decode a compressed, fully rendered UI. Changing their payload contract would risk breaking active Live Activities and older app releases. + +Dynamic Live Activities need a stable ActivityKit contract that is distinct from the legacy engine. Each definition is declared in app configuration with a stable ID and an entry module. It receives a generated ActivityKit type while reusing the shared rendering runtime. + +Dynamic definitions are configured alongside Home Screen widgets: + +```json +{ + "liveActivities": [ + { + "id": "order_finished", + "entry": "./live-activities/order-finished.tsx" + } + ] +} +``` + +Only Dynamic Live Activities require declarations. Existing server-rendered Live Activities remain undeclared. + +Definition IDs follow the existing widget ID rule: they contain only alphanumeric characters and underscores. They must be unique within the Dynamic Live Activity collection. + +Configuring at least one Dynamic Live Activity requires `groupIdentifier`. Prebuild validation fails when `liveActivities` is non-empty and no App Group is configured. + +For V1, props are an opaque JSON-compatible record. Voltra assumes that producers send the complete and correct props expected by the selected definition. Definition-specific prop schemas, generated prop types, and runtime prop validation are out of scope. + +## Decision + +- Keep the legacy engine, `VoltraAttributes`, payload format, APIs, and `VoltraWidget` configuration unchanged. +- Generate one `ActivityAttributes` type and matching ActivityKit configuration for each declared Dynamic Live Activity. Convert its underscore-delimited ID to UpperCamelCase and use `VoltraLiveActivityAttributes` as the type name; for example, `order_finished` becomes `VoltraOrderFinishedLiveActivityAttributes`. Fail prebuild if two IDs produce the same generated type name. +- Have every generated attributes type reuse the same generic content-state implementation. Store the current props record directly under `props`; each update replaces the complete props record. Encode it as a JSON object in ActivityKit payloads and serialize it only when crossing into the JavaScript runtime. Static attributes contain the activity name and optional deep link; the generated type and configuration identify the definition. +- Generate a catalog and bundled entry for every Dynamic Live Activity declared in app configuration. Each generated ActivityKit configuration passes its definition ID to the shared renderer. +- Keep Dynamic Live Activity definitions in a namespace separate from Dynamic Widgets: a separate manifest collection, Metro bundle route, runtime registry, and release asset prefix. IDs are unique within each collection, so a Dynamic Widget and Dynamic Live Activity may share an ID. Reuse the underlying JavaScript runtime and rendering primitives. +- Support the same development model as Dynamic Widgets: load definitions from the dedicated Metro route in debug builds, bundle them as release assets, and use Fast Refresh to invalidate the changed definition and re-render active Dynamic Live Activities that use it. +- Define each entry as a function of `(props, environment)` that returns the complete existing `LiveActivityVariants` shape. The on-device renderer resolves all declared Lock Screen, Dynamic Island, and supplemental-family variants from that result. +- Define `LiveActivityEnvironment` by reusing `date`, `colorScheme`, `locale`, `widgetRenderingMode`, and `build` from `WidgetEnvironment`, and adding ActivityKit's `isStale` and optional `activityFamily`. Do not expose Home Screen-only `widgetFamily`, `showsWidgetContainerBackground`, or `configuration` fields. +- Use the generated attributes type in the push-to-start payload. An app release that does not contain that definition's type and ActivityKit configuration does not accept the push, including an older release that supports other Dynamic Live Activities. +- Reject missing catalog entries or bundled resources before local creation. For remotely started activities, or failures discovered after creation, log the failure, render `EmptyView`, and keep the activity active so a later update can recover it. V1 does not cache the last successfully rendered UI. +- Add explicit `useDynamicLiveActivity`, `startDynamicLiveActivity`, and `updateDynamicLiveActivity` client APIs. Do not overload the legacy `useLiveActivity`, `startLiveActivity`, or `updateLiveActivity` APIs. Update APIs are engine-specific: when an activity name belongs to the other engine, reject with a renderer-mismatch error instead of reporting that the activity was not found. Ending and shared lifecycle operations work across both engines. +- Add `getDynamicLiveActivityDefinitionIds()` to return the definition IDs bundled in the installed release. Applications can register this capability list alongside the unchanged app-wide push-to-start token. +- Export the generic dynamic props and content-state TypeScript types for server use. Also export `getDynamicLiveActivityAttributesType(definitionId)` so push producers use the same generated UpperCamelCase type name as prebuild. Do not add a dynamic render or payload-construction helper in V1 because props are inserted directly into ActivityKit's `content-state` without rendering, compression, or transformation. +- Validate the encoded attributes and content state against the existing ActivityKit 4 KB limit before local dynamic starts and updates. Reject oversized local operations before calling ActivityKit. Server producers remain responsible for the size of their complete APNs payloads. +- Generate the ActivityKit configuration and per-activity lifecycle and update-token observation needed by each declared type. Keep the single existing app-wide push-to-start token observer. Manage the legacy type and generated dynamic types separately while presenting a unified public lifecycle API where appropriate. +- Keep the existing push-to-start and per-activity update-token event contracts unchanged. Push-to-start uses the existing app-wide token, while an update token already targets one activity instance and is associated with its existing activity name. Applications and servers may track renderer and supported-definition capabilities separately when routing pushes. +- Encapsulate the cross-target native implementation under a dedicated `packages/ios-client/ios/shared/dynamic-live-activity/` directory compiled into both the app and widget extension. Keep feature-specific attributes, catalog lookup, runtime coordination, and payload handling there instead of spreading them through the legacy Live Activity implementation. +- Record rendering failures in a dedicated App Group queue capped at 100 events, dropping the oldest event when full and performing no deduplication in V1. Notify the app process after persisting a failure so a running app can drain the queue without polling; also drain it when listeners are established and when the app enters the foreground. Flush failures through the existing JavaScript event path without allowing them to displace persistent interaction events. Expose failures as `dynamicLiveActivityRenderFailed` events through the native `onDynamicLiveActivityRenderFailed` emitter and `addVoltraListener`. Failure events reuse the common `type`, `source`, and `timestamp` properties, set `source` to the activity name, and add `activityName`, `definitionId`, and a sanitized `message`; they do not include a separate stage, props, tokens, or other payload data. Also write the failure to `OSLog` for local diagnostics. +- Require an App Group for Dynamic Live Activities so the widget extension can persist failure events and the app can flush them reliably. +- Treat Dynamic Live Activities and their public APIs as experimental in V1. + +The Voltra-specific ActivityKit payload fields are: + +```json +{ + "attributes-type": "VoltraOrderFinishedLiveActivityAttributes", + "attributes": { + "name": "order-123", + "deepLinkUrl": "myapp://orders/123" + }, + "content-state": { + "props": { + "status": "delivering" + } + } +} +``` + +`deepLinkUrl` is optional. Update and end pushes omit the static attributes and replace the complete `content-state.props` record. Standard ActivityKit fields such as timestamps, alerts, stale dates, relevance scores, dismissal dates, and channel fields retain their existing behavior. + +## Compatibility and edge cases + +- **Older app release:** A dynamic push-to-start references the generated attributes type for its definition. A release that does not contain that exact type and ActivityKit configuration does not create the activity, even if it supports other Dynamic Live Activities. +- **Definition missing from app configuration:** The release does not contain the definition's generated attributes type, configuration, catalog entry, or bundle. ActivityKit does not create an activity from a push that names that type, and local APIs reject the unknown ID. +- **Definition removed in a later release:** Definitions must remain bundled while activities using them may still be active. Otherwise those activities must be ended before the definition is removed. +- **Props change between releases:** A definition ID represents a stable rendering and props contract. A breaking props change requires a new definition ID, such as `order_finished_v2`. V1 does not validate props and treats incompatible props sent under the same ID as producer error. +- **Dynamic update:** The ActivityKit update token already identifies the activity instance, and its generated attributes type identifies the definition. Update payloads contain only the new complete props record. +- **Name collision between engines:** Local starts retain the existing replacement behavior and end activities with the same name across both engines when replacement is enabled. ActivityKit handles remote push-to-start without running this logic and may create activities with duplicate names. Voltra does not add duplicate detection or reconciliation for remote starts in V1. +- **Wrong update API:** Updating a dynamic activity through the legacy API, or a legacy activity through the dynamic API, fails with a renderer-mismatch error. No update is applied. +- **Mixed-version broadcast channel:** A broadcast cannot tailor its payload per recipient. Dynamic activities must use a channel whose recipients support the same definition; otherwise the channel remains on the legacy format. +- **Missing or corrupt generated bundle:** Local starts reject a missing resource. If a remotely started or existing activity reaches rendering without a usable bundle, the widget extension renders no content and records a failure without crashing. +- **Late runtime failure:** A failure after ActivityKit creates the activity cannot retroactively reject the start. The activity remains active but renders no content until a later render succeeds or the activity ends. + +## Consequences + +- Legacy and dynamic Live Activities can coexist during rollout without changing the existing payload format. +- Each definition adds a generated native ActivityKit type, configuration, per-activity observer, catalog entry, and bundle while reusing shared content-state and rendering code. The app-wide push-to-start token observer remains shared. +- Native lifecycle management must cover `Activity` and every generated Dynamic Live Activity type. +- Applications and servers that route both payload formats must associate activity names and app capabilities with the unchanged token events. +- V1 deliberately provides no guarantee that a props record matches the entry's TypeScript expectations. +- Render failures are observable on the next app run, but delivery is diagnostic and does not change the activity lifecycle. +- Dynamic Live Activities require the additional App Group configuration and entitlement even when their props arrive exclusively through ActivityKit pushes. + +## Alternatives considered + +### Extend `VoltraAttributes` with both payload formats + +Rejected because it weakens the compatibility boundary and changes the decoder used by existing Live Activities. + +### Use one shared Dynamic Live Activity attributes type + +Rejected because an older app release that knows the shared type would accept a push for a definition introduced in a later release. A generated type per definition lets ActivityKit reject that push before creating an unsupported activity. + +### Include legacy and dynamic representations in every push + +Rejected because it increases payload size, makes renderer selection ambiguous, and undermines the main benefit of the dynamic engine. + +### Validate definition-specific props in V1 + +Deferred. V1 accepts a generic JSON-compatible record and assumes the producer follows the definition's contract. From 007c75ac18ff117f2cfa8543563c02a10ca8ac63 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 20:55:10 +0200 Subject: [PATCH 03/24] feat(ios): add dynamic live activity contracts --- .../expo-plugin/src/dynamic-live-activity.ts | 13 +++ packages/expo-plugin/src/index.ts | 2 + packages/expo-plugin/src/types.ts | 9 ++ .../ios-client/expo-plugin/jest.config.js | 1 + packages/ios-client/expo-plugin/src/index.ts | 1 + packages/ios-client/expo-plugin/src/types.ts | 15 ++- .../expo-plugin/src/validation.node.test.ts | 92 +++++++++++++++++++ .../ios-client/expo-plugin/src/validation.ts | 52 ++++++++++- packages/ios-server/src/index.ts | 19 +++- packages/ios/src/index.ts | 6 ++ packages/ios/src/live-activity/dynamic.ts | 36 ++++++++ 11 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 packages/expo-plugin/src/dynamic-live-activity.ts create mode 100644 packages/ios/src/live-activity/dynamic.ts diff --git a/packages/expo-plugin/src/dynamic-live-activity.ts b/packages/expo-plugin/src/dynamic-live-activity.ts new file mode 100644 index 00000000..d6657a6a --- /dev/null +++ b/packages/expo-plugin/src/dynamic-live-activity.ts @@ -0,0 +1,13 @@ +/** + * Returns the generated ActivityKit attributes type name for a definition ID. + * @experimental + */ +export function getDynamicLiveActivityAttributesType(definitionId: string): string { + const upperCamelCaseId = definitionId + .split('_') + .filter(Boolean) + .map((segment) => `${segment[0].toUpperCase()}${segment.slice(1)}`) + .join('') + + return `Voltra${upperCamelCaseId}LiveActivityAttributes` +} diff --git a/packages/expo-plugin/src/index.ts b/packages/expo-plugin/src/index.ts index 8968f78f..028c466f 100644 --- a/packages/expo-plugin/src/index.ts +++ b/packages/expo-plugin/src/index.ts @@ -1,6 +1,8 @@ export { MAX_IMAGE_SIZE_BYTES, MODULE_EXTENSIONS } from './constants' +export { getDynamicLiveActivityAttributesType } from './dynamic-live-activity' export type { DynamicWidgetEntryConfig, + DynamicLiveActivityEntryConfig, DynamicWidgetManifest, DynamicWidgetManifestWidget, DynamicWidgetPlatform, diff --git a/packages/expo-plugin/src/types.ts b/packages/expo-plugin/src/types.ts index 641ace16..d22ccaa9 100644 --- a/packages/expo-plugin/src/types.ts +++ b/packages/expo-plugin/src/types.ts @@ -26,6 +26,15 @@ export interface DynamicWidgetManifest { widgets: DynamicWidgetManifestWidget[] } +/** + * Shared app.json entry contract for Dynamic Live Activities. + * @experimental + */ +export interface DynamicLiveActivityEntryConfig { + id: string + entry: string +} + /** * Per-locale strings for widget picker/gallery labels (`displayName`, `description`). * Keys should be BCP-47-style locale tags (e.g. `en`, `pl`, `pt-BR`). Plain `string` is still allowed for a single-language setup. diff --git a/packages/ios-client/expo-plugin/jest.config.js b/packages/ios-client/expo-plugin/jest.config.js index 4e24b4de..a46d2d65 100644 --- a/packages/ios-client/expo-plugin/jest.config.js +++ b/packages/ios-client/expo-plugin/jest.config.js @@ -4,6 +4,7 @@ module.exports = { testMatch: ['/src/**/*.node.test.ts'], modulePathIgnorePatterns: ['/build'], moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', '^@use-voltra/compiler$': '/../../compiler/src/index.ts', '^@use-voltra/expo-plugin$': '/../../expo-plugin/src/index.ts', '^@use-voltra/expo-plugin/(.*)$': '/../../expo-plugin/src/$1', diff --git a/packages/ios-client/expo-plugin/src/index.ts b/packages/ios-client/expo-plugin/src/index.ts index 1f9b7301..3f52f369 100644 --- a/packages/ios-client/expo-plugin/src/index.ts +++ b/packages/ios-client/expo-plugin/src/index.ts @@ -67,6 +67,7 @@ export default withVoltraIos export type { IOSConfigPluginProps, + IOSDynamicLiveActivityConfig, IOSMainAppPluginProps, IOSWidgetConfig, IOSWidgetExtensionFiles, diff --git a/packages/ios-client/expo-plugin/src/types.ts b/packages/ios-client/expo-plugin/src/types.ts index e52ad60a..bc54fa39 100644 --- a/packages/ios-client/expo-plugin/src/types.ts +++ b/packages/ios-client/expo-plugin/src/types.ts @@ -1,6 +1,11 @@ import type { ConfigPlugin } from '@expo/config-plugins' -import type { DynamicWidgetEntryConfig, WidgetInitialStatePath, WidgetLabel } from '@use-voltra/expo-plugin' +import type { + DynamicLiveActivityEntryConfig, + DynamicWidgetEntryConfig, + WidgetInitialStatePath, + WidgetLabel, +} from '@use-voltra/expo-plugin' /** * Supported iOS Home Screen widget size families. @@ -60,6 +65,12 @@ export interface IOSWidgetConfig extends DynamicWidgetEntryConfig { appIntent?: IOSWidgetAppIntentConfig } +/** + * A Dynamic Live Activity bundled with the app. + * @experimental + */ +export interface IOSDynamicLiveActivityConfig extends DynamicLiveActivityEntryConfig {} + /** * Server-driven iOS widget updates (WidgetKit background refresh). */ @@ -91,6 +102,8 @@ export interface IOSConfigPluginProps { enablePushNotifications?: boolean groupIdentifier?: string widgets?: IOSWidgetConfig[] + /** @experimental Dynamic Live Activities rendered from bundled JavaScript entries. */ + liveActivities?: IOSDynamicLiveActivityConfig[] deploymentTarget?: string targetName?: string fonts?: string[] diff --git a/packages/ios-client/expo-plugin/src/validation.node.test.ts b/packages/ios-client/expo-plugin/src/validation.node.test.ts index 36b72d0c..fbaf509e 100644 --- a/packages/ios-client/expo-plugin/src/validation.node.test.ts +++ b/packages/ios-client/expo-plugin/src/validation.node.test.ts @@ -2,6 +2,8 @@ import * as fs from 'fs' import * as os from 'os' import * as path from 'path' +import { getDynamicLiveActivityAttributesType } from '@use-voltra/expo-plugin' + import { validateIOSConfigPluginProps } from './validation' function createProjectRoot(files: Record): string { @@ -96,4 +98,94 @@ describe('validateIOSConfigPluginProps', () => { fs.rmSync(projectRoot, { recursive: true, force: true }) } }) + + it('accepts Dynamic Live Activities independently from widget IDs', () => { + const projectRoot = createProjectRoot({ + 'widgets/order-finished.tsx': 'export default function OrderFinished() {}', + 'live-activities/order-finished.tsx': 'export default function OrderFinished() {}', + }) + + try { + expect(() => + validateIOSConfigPluginProps( + { + groupIdentifier: 'group.com.example.app', + widgets: [ + { + id: 'order_finished', + entry: './widgets/order-finished.tsx', + displayName: 'Order finished', + description: 'Order status', + }, + ], + liveActivities: [{ id: 'order_finished', entry: './live-activities/order-finished.tsx' }], + }, + projectRoot + ) + ).not.toThrow() + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }) + } + }) + + it('requires an App Group for Dynamic Live Activities', () => { + expect(() => + validateIOSConfigPluginProps({ + liveActivities: [{ id: 'order_finished', entry: './live-activities/order-finished.tsx' }], + }) + ).toThrow(/groupIdentifier is required when liveActivities is non-empty/) + }) + + it.each(['', 'order-finished', 'order finished', 'order.finished'])( + 'rejects invalid Dynamic Live Activity ID %p', + (id) => { + expect(() => + validateIOSConfigPluginProps({ + groupIdentifier: 'group.com.example.app', + liveActivities: [{ id, entry: './live-activities/order-finished.tsx' }], + }) + ).toThrow(/Dynamic Live Activity ID/) + } + ) + + it.each([ + ['./live-activities/order-finished.txt', /Metro-importable source extension/], + ['../live-activities/order-finished.tsx', /must stay within the project root/], + ])('rejects invalid Dynamic Live Activity entry %p', (entry, error) => { + expect(() => + validateIOSConfigPluginProps({ + groupIdentifier: 'group.com.example.app', + liveActivities: [{ id: 'order_finished', entry }], + }) + ).toThrow(error) + }) + + it('rejects duplicate Dynamic Live Activity IDs', () => { + expect(() => + validateIOSConfigPluginProps({ + groupIdentifier: 'group.com.example.app', + liveActivities: [ + { id: 'order_finished', entry: './live-activities/order-finished.tsx' }, + { id: 'order_finished', entry: './live-activities/order-finished-v2.tsx' }, + ], + }) + ).toThrow(/Duplicate Dynamic Live Activity ID/) + }) + + it('rejects IDs that generate the same ActivityKit attributes type', () => { + expect(() => + validateIOSConfigPluginProps({ + groupIdentifier: 'group.com.example.app', + liveActivities: [ + { id: 'order_finished', entry: './live-activities/order-finished.tsx' }, + { id: 'order__finished', entry: './live-activities/order-finished-v2.tsx' }, + ], + }) + ).toThrow(/generate the same ActivityKit attributes type/) + }) + + it('uses the public naming helper for generated attributes types', () => { + expect(getDynamicLiveActivityAttributesType('order_finished')).toBe('VoltraOrderFinishedLiveActivityAttributes') + expect(getDynamicLiveActivityAttributesType('order__finished')).toBe('VoltraOrderFinishedLiveActivityAttributes') + }) }) diff --git a/packages/ios-client/expo-plugin/src/validation.ts b/packages/ios-client/expo-plugin/src/validation.ts index 560c739f..fbd56d75 100644 --- a/packages/ios-client/expo-plugin/src/validation.ts +++ b/packages/ios-client/expo-plugin/src/validation.ts @@ -4,8 +4,9 @@ import { validateWidgetEntry, validateWidgetLabel, } from '@use-voltra/expo-plugin' +import { getDynamicLiveActivityAttributesType } from '@use-voltra/expo-plugin' -import type { IOSConfigPluginProps, IOSWidgetConfig, IOSWidgetFamily } from './types' +import type { IOSConfigPluginProps, IOSDynamicLiveActivityConfig, IOSWidgetConfig, IOSWidgetFamily } from './types' const VALID_FAMILIES: Set = new Set([ 'systemSmall', @@ -16,6 +17,25 @@ const VALID_FAMILIES: Set = new Set([ 'accessoryRectangular', 'accessoryInline', ]) +const DYNAMIC_LIVE_ACTIVITY_ID_PATTERN = /^[a-zA-Z0-9_]+$/ + +export function validateIOSDynamicLiveActivityConfig( + liveActivity: IOSDynamicLiveActivityConfig, + projectRoot?: string +): void { + if (!liveActivity.id || typeof liveActivity.id !== 'string') { + throw new Error('Dynamic Live Activity ID is required and must be a string') + } + + if (!DYNAMIC_LIVE_ACTIVITY_ID_PATTERN.test(liveActivity.id)) { + throw new Error( + `Dynamic Live Activity ID '${liveActivity.id}' is invalid. ` + + 'It must be non-empty and contain only alphanumeric characters and underscores.' + ) + } + + validateWidgetEntry(liveActivity.entry, liveActivity.id, projectRoot) +} export function validateIOSWidgetConfig(widget: IOSWidgetConfig, projectRoot?: string): void { validateHomeScreenWidgetId(widget.id) @@ -69,4 +89,34 @@ export function validateIOSConfigPluginProps(props: IOSConfigPluginProps, projec seenIds.add(widget.id) } } + + if (props.liveActivities !== undefined) { + if (!Array.isArray(props.liveActivities)) { + throw new Error('liveActivities must be an array') + } + + if (props.liveActivities.length > 0 && !props.groupIdentifier) { + throw new Error('groupIdentifier is required when liveActivities is non-empty') + } + + const seenIds = new Set() + const seenAttributesTypes = new Map() + for (const liveActivity of props.liveActivities) { + validateIOSDynamicLiveActivityConfig(liveActivity, projectRoot) + + if (seenIds.has(liveActivity.id)) { + throw new Error(`Duplicate Dynamic Live Activity ID: '${liveActivity.id}'`) + } + seenIds.add(liveActivity.id) + + const attributesType = getDynamicLiveActivityAttributesType(liveActivity.id) + const conflictingId = seenAttributesTypes.get(attributesType) + if (conflictingId) { + throw new Error( + `Dynamic Live Activity IDs '${conflictingId}' and '${liveActivity.id}' generate the same ActivityKit attributes type '${attributesType}'` + ) + } + seenAttributesTypes.set(attributesType, liveActivity.id) + } + } } diff --git a/packages/ios-server/src/index.ts b/packages/ios-server/src/index.ts index 5a15ace5..073906fb 100644 --- a/packages/ios-server/src/index.ts +++ b/packages/ios-server/src/index.ts @@ -4,7 +4,15 @@ import { promisify } from 'node:util' import { brotliCompress, constants } from 'node:zlib' import { type ComponentRegistry, createVoltraRenderer, ensurePayloadWithinBudget } from '@use-voltra/core' -import { getComponentId, type LiveActivityVariants, type WidgetVariants } from '@use-voltra/ios' +import { + getComponentId, + getDynamicLiveActivityAttributesType, + type DynamicLiveActivityContentState, + type DynamicLiveActivityProps, + type DynamicLiveActivityPropsValue, + type LiveActivityVariants, + type WidgetVariants, +} from '@use-voltra/ios' import type { WidgetRenderRequest, WidgetUpdateExpressHandler, @@ -19,7 +27,14 @@ import { import type { ReactNode } from 'react' export { Voltra } from '@use-voltra/ios' -export type { LiveActivityVariants, WidgetVariants } +export { getDynamicLiveActivityAttributesType } +export type { + DynamicLiveActivityContentState, + DynamicLiveActivityProps, + DynamicLiveActivityPropsValue, + LiveActivityVariants, + WidgetVariants, +} export type { WidgetRenderRequest, WidgetUpdateExpressHandler, diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts index f296104f..ec6883cd 100644 --- a/packages/ios/src/index.ts +++ b/packages/ios/src/index.ts @@ -8,12 +8,18 @@ export { COMPONENT_NAME_TO_ID, } from './payload/component-ids.js' export { renderLiveActivityToJson, renderLiveActivityToString } from './live-activity/renderer.js' +export { getDynamicLiveActivityAttributesType } from './live-activity/dynamic.js' export type { DismissalPolicy, LiveActivityJson, LiveActivityVariants, LiveActivityVariantsJson, } from './live-activity/types.js' +export type { + DynamicLiveActivityContentState, + DynamicLiveActivityProps, + DynamicLiveActivityPropsValue, +} from './live-activity/dynamic.js' export { renderVoltraVariantToJson } from './renderer/index.js' export type { VoltraStyleProp, VoltraTextStyle, VoltraTextStyleProp, VoltraViewStyle } from './styles/index.js' export type { diff --git a/packages/ios/src/live-activity/dynamic.ts b/packages/ios/src/live-activity/dynamic.ts new file mode 100644 index 00000000..f83df663 --- /dev/null +++ b/packages/ios/src/live-activity/dynamic.ts @@ -0,0 +1,36 @@ +/** + * A JSON-compatible value accepted as Dynamic Live Activity props. + * + * Dynamic Live Activities deliberately leave the record opaque in V1: the + * bundled definition and its server producer own the props contract. + * @experimental + */ +export type DynamicLiveActivityPropsValue = + | string + | number + | boolean + | null + | DynamicLiveActivityPropsValue[] + | { [key: string]: DynamicLiveActivityPropsValue } + +/** @experimental A complete JSON-compatible props record for a Dynamic Live Activity update. */ +export type DynamicLiveActivityProps = Record + +/** @experimental The generic ActivityKit content-state shape used by every Dynamic Live Activity. */ +export interface DynamicLiveActivityContentState { + props: DynamicLiveActivityProps +} + +/** + * Returns the generated ActivityKit attributes type name for a definition ID. + * @experimental + */ +export function getDynamicLiveActivityAttributesType(definitionId: string): string { + const upperCamelCaseId = definitionId + .split('_') + .filter(Boolean) + .map((segment) => `${segment[0].toUpperCase()}${segment.slice(1)}`) + .join('') + + return `Voltra${upperCamelCaseId}LiveActivityAttributes` +} From fe8dc549eef9e632769869c7c357eb60edebdcf3 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 21:00:00 +0200 Subject: [PATCH 04/24] feat(metro): bundle dynamic live activities separately --- packages/expo-plugin/src/index.ts | 2 + packages/expo-plugin/src/types.ts | 16 ++ packages/metro/src/bundleWidgets.ts | 26 +- packages/metro/src/createVoltraMiddleware.ts | 45 +++- packages/metro/src/index.ts | 37 ++- packages/metro/src/liveActivityRegistry.ts | 236 ++++++++++++++++++ .../metro/src/widgetRegistry.node.test.ts | 129 ++++++++++ 7 files changed, 483 insertions(+), 8 deletions(-) create mode 100644 packages/metro/src/liveActivityRegistry.ts diff --git a/packages/expo-plugin/src/index.ts b/packages/expo-plugin/src/index.ts index 028c466f..bbfb49d2 100644 --- a/packages/expo-plugin/src/index.ts +++ b/packages/expo-plugin/src/index.ts @@ -3,6 +3,8 @@ export { getDynamicLiveActivityAttributesType } from './dynamic-live-activity' export type { DynamicWidgetEntryConfig, DynamicLiveActivityEntryConfig, + DynamicLiveActivityManifest, + DynamicLiveActivityManifestDefinition, DynamicWidgetManifest, DynamicWidgetManifestWidget, DynamicWidgetPlatform, diff --git a/packages/expo-plugin/src/types.ts b/packages/expo-plugin/src/types.ts index d22ccaa9..950e72f1 100644 --- a/packages/expo-plugin/src/types.ts +++ b/packages/expo-plugin/src/types.ts @@ -26,6 +26,22 @@ export interface DynamicWidgetManifest { widgets: DynamicWidgetManifestWidget[] } +/** + * Generated by the iOS config plugin for Metro's Dynamic Live Activity pipeline. + * This intentionally does not share the Dynamic Widget manifest: an ID may exist in + * both collections and each collection has a separate runtime and bundle namespace. + */ +export interface DynamicLiveActivityManifestDefinition { + id: string + entry: string +} + +export interface DynamicLiveActivityManifest { + version: 1 + platform: 'ios' + liveActivities: DynamicLiveActivityManifestDefinition[] +} + /** * Shared app.json entry contract for Dynamic Live Activities. * @experimental diff --git a/packages/metro/src/bundleWidgets.ts b/packages/metro/src/bundleWidgets.ts index 6e208ac7..bfa7940d 100644 --- a/packages/metro/src/bundleWidgets.ts +++ b/packages/metro/src/bundleWidgets.ts @@ -6,6 +6,11 @@ import type { DynamicWidgetPlatform } from '@use-voltra/expo-plugin' import { createWidgetMetroConfig } from './createWidgetMetroConfig' import { requireProjectModule } from './resolveProjectModule' import { createWidgetRegistry } from './widgetRegistry' +import { + createLiveActivityRegistry, + MissingVoltraLiveActivitiesManifestError, + type RegisteredVoltraLiveActivity, +} from './liveActivityRegistry' export type BundleWidgetsOptions = { projectRoot: string @@ -62,12 +67,21 @@ export async function bundleWidgets({ projectRoot, outDir, platform }: BundleWid } const registry = createWidgetRegistry({ projectRoot }) + const liveActivityRegistry = platform === 'ios' ? createLiveActivityRegistry({ projectRoot }) : null try { const widgets = registry.listWidgets(platform) + let liveActivities: RegisteredVoltraLiveActivity[] = [] + if (liveActivityRegistry) { + try { + liveActivities = liveActivityRegistry.listLiveActivities() + } catch (error) { + if (!(error instanceof MissingVoltraLiveActivitiesManifestError)) throw error + } + } - if (widgets.length === 0) { - console.log(`[voltra] no Dynamic Widgets to bundle for platform "${platform}"`) + if (widgets.length === 0 && liveActivities.length === 0) { + console.log(`[voltra] no Dynamic Widgets or Dynamic Live Activities to bundle for platform "${platform}"`) return } @@ -93,8 +107,16 @@ export async function bundleWidgets({ projectRoot, outDir, platform }: BundleWid fs.writeFileSync(outPath, code) console.log(`[voltra] baked ${path.basename(outPath)} (${code.length} bytes)`) } + for (const liveActivity of liveActivities) { + const entry = path.resolve(projectRoot, liveActivity.generatedEntryRelativePath) + const { code } = await Metro.runBuild(widgetConfig, { entry, platform, dev: false, minify: true }) + const outPath = path.join(outDir, `voltra-live-activity-${liveActivity.id}.bundle`) + fs.writeFileSync(outPath, code) + console.log(`[voltra] baked ${path.basename(outPath)} (${code.length} bytes)`) + } } finally { registry.close() + liveActivityRegistry?.close() } } diff --git a/packages/metro/src/createVoltraMiddleware.ts b/packages/metro/src/createVoltraMiddleware.ts index 1187ff21..8b0d3408 100644 --- a/packages/metro/src/createVoltraMiddleware.ts +++ b/packages/metro/src/createVoltraMiddleware.ts @@ -1,4 +1,5 @@ import type { WidgetRegistry } from './widgetRegistry' +import type { LiveActivityRegistry } from './liveActivityRegistry' type Middleware = (req: any, res: any, next: () => void) => void @@ -18,21 +19,23 @@ function sendJson(res: any, status: number, value: unknown): void { } function createBundleRequest( - widget: { generatedEntryRelativePath: string; platform: 'ios' | 'android' }, + entry: { generatedEntryRelativePath: string; platform: 'ios' | 'android' }, originalSearchParams: URLSearchParams ): string { const query = new URLSearchParams(originalSearchParams) - query.set('bundleEntry', widget.generatedEntryRelativePath) - query.set('platform', widget.platform) + query.set('bundleEntry', entry.generatedEntryRelativePath) + query.set('platform', entry.platform) return `/voltra-widget.bundle?${query.toString()}` } export function createVoltraMiddleware({ registry, + liveActivityRegistry, widgetMetro, }: { registry: WidgetRegistry + liveActivityRegistry?: LiveActivityRegistry widgetMetro: { middleware: Middleware } }): Middleware { return (req, res, next) => { @@ -48,8 +51,16 @@ export function createVoltraMiddleware({ return } - if (pathname === '/' || pathname === '/widgets') { + if (pathname === '/' || pathname === '/widgets' || pathname === '/live-activities') { try { + if (pathname === '/live-activities') { + sendJson(res, 200, { + ready: liveActivityRegistry?.isReady() ?? false, + platform: 'ios', + liveActivities: liveActivityRegistry?.listLiveActivities() ?? [], + }) + return + } sendJson(res, 200, { ready: registry.isReady(), platform: requestedPlatform, @@ -93,6 +104,32 @@ export function createVoltraMiddleware({ } } + const liveActivityBundleMatch = pathname.match(/^\/live-activities\/([^/]+)\.bundle$/) + if (liveActivityBundleMatch) { + const definitionId = decodeURIComponent(liveActivityBundleMatch[1]) + if (hasPlatformParam && requestedPlatform !== 'ios') { + sendJson(res, 400, { error: 'Dynamic Live Activities are available only for platform "ios".' }) + return + } + if (!liveActivityRegistry) { + sendJson(res, 404, { error: `Unknown Voltra Dynamic Live Activity "${definitionId}" for platform "ios".` }) + return + } + try { + const liveActivity = liveActivityRegistry.getLiveActivity(definitionId) + if (!liveActivity) { + sendJson(res, 404, { error: `Unknown Voltra Dynamic Live Activity "${definitionId}" for platform "ios".` }) + return + } + req.url = createBundleRequest(liveActivity, requestUrl.searchParams) + widgetMetro.middleware(req, res, next) + return + } catch (error) { + sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) }) + return + } + } + sendJson(res, 404, { error: `Unknown Voltra endpoint "${pathname}".`, }) diff --git a/packages/metro/src/index.ts b/packages/metro/src/index.ts index 7f2d2d3c..ca926592 100644 --- a/packages/metro/src/index.ts +++ b/packages/metro/src/index.ts @@ -6,6 +6,7 @@ import { createMetroConfigTransformer } from 'metro-config-transformers' import { bundleWidgets } from './bundleWidgets' import { createVoltraMiddleware } from './createVoltraMiddleware' import { createWidgetMetroConfig } from './createWidgetMetroConfig' +import { createLiveActivityRegistry, MissingVoltraLiveActivitiesManifestError } from './liveActivityRegistry' import { requireProjectModule } from './resolveProjectModule' import { createWidgetRegistry, @@ -16,6 +17,7 @@ import { } from './widgetRegistry' const HOT_RELOAD_ALIAS = '@use-voltra/widget-hot-reload' +const LIVE_ACTIVITY_HOT_RELOAD_ALIAS = '@use-voltra/live-activity-hot-reload' const DEV_BARREL_PLATFORMS = new Set(['ios', 'android']) const WIDGET_PLATFORMS = ['ios', 'android'] as const @@ -38,6 +40,12 @@ function resolveHotReloadAlias(projectRoot: string, context: any, platform: stri return { type: 'sourceFile', filePath: platformBarrel } } +function resolveLiveActivityHotReloadAlias(projectRoot: string, context: any, platform: string | null): unknown { + if (!context.dev || platform !== 'ios') return { type: 'empty' } + const barrel = path.join(projectRoot, '.voltra', 'metro', 'live-activity-hot-reload.ios.js') + return fs.existsSync(barrel) ? { type: 'sourceFile', filePath: barrel } : { type: 'empty' } +} + function createResolveRequest( projectRoot: string, previousResolveRequest: ResolveRequest | null | undefined @@ -46,6 +54,9 @@ function createResolveRequest( if (moduleName === HOT_RELOAD_ALIAS) { return resolveHotReloadAlias(projectRoot, context, platform) } + if (moduleName === LIVE_ACTIVITY_HOT_RELOAD_ALIAS) { + return resolveLiveActivityHotReloadAlias(projectRoot, context, platform) + } if (previousResolveRequest) { return previousResolveRequest(context, moduleName, platform) @@ -76,6 +87,7 @@ function listConfiguredWidgets(registry: WidgetRegistry): RegisteredVoltraWidget export const withVoltra = createMetroConfigTransformer(async (metroConfig: any) => { const projectRoot = metroConfig.projectRoot ?? process.cwd() const registry = createWidgetRegistry({ projectRoot }) + const liveActivityRegistry = createLiveActivityRegistry({ projectRoot }) const previousResolveRequest = metroConfig.resolver?.resolveRequest const configWithResolver = { ...metroConfig, @@ -87,9 +99,16 @@ export const withVoltra = createMetroConfigTransformer(async (metroConfig: any) } const configuredWidgets = listConfiguredWidgets(registry) + let configuredLiveActivities = [] + try { + configuredLiveActivities = liveActivityRegistry.listLiveActivities() + } catch (error) { + if (!(error instanceof MissingVoltraLiveActivitiesManifestError)) throw error + } - if (configuredWidgets.length === 0) { + if (configuredWidgets.length === 0 && configuredLiveActivities.length === 0) { registry.close() + liveActivityRegistry.close() return configWithResolver } @@ -110,6 +129,7 @@ export const withVoltra = createMetroConfigTransformer(async (metroConfig: any) }) const voltraMiddleware = createVoltraMiddleware({ registry, + liveActivityRegistry, widgetMetro, }) @@ -128,7 +148,20 @@ export const withVoltra = createMetroConfigTransformer(async (metroConfig: any) } }) -export { bundleWidgets, createVoltraMiddleware, createWidgetMetroConfig, createWidgetRegistry } +export { + bundleWidgets, + createVoltraMiddleware, + createWidgetMetroConfig, + createWidgetRegistry, + createLiveActivityRegistry, +} export { requireProjectModule, resolveProjectModulePath } from './resolveProjectModule' export { scanVoltraDirectives, type VoltraDirectiveWidget } from './scanner' export { DuplicateVoltraWidgetError, type RegisteredVoltraWidget, type WidgetRegistry } from './widgetRegistry' +export { + DuplicateVoltraLiveActivityError, + InvalidVoltraLiveActivitiesManifestError, + MissingVoltraLiveActivitiesManifestError, + type LiveActivityRegistry, + type RegisteredVoltraLiveActivity, +} from './liveActivityRegistry' diff --git a/packages/metro/src/liveActivityRegistry.ts b/packages/metro/src/liveActivityRegistry.ts new file mode 100644 index 00000000..3cd89fe8 --- /dev/null +++ b/packages/metro/src/liveActivityRegistry.ts @@ -0,0 +1,236 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +import { + type DynamicLiveActivityManifest, + type DynamicLiveActivityManifestDefinition, + validateWidgetEntry, +} from '@use-voltra/expo-plugin' + +const MANIFEST_RELATIVE_PATH = '.voltra/manifest.ios.live-activities.json' + +export type RegisteredVoltraLiveActivity = DynamicLiveActivityManifestDefinition & { + platform: 'ios' + manifestPath: string + generatedEntryPath: string + generatedEntryRelativePath: string +} + +export type LiveActivityRegistry = { + projectRoot: string + getLiveActivity(definitionId: string): RegisteredVoltraLiveActivity | null + isReady(): boolean + listLiveActivities(): RegisteredVoltraLiveActivity[] + close(): void +} + +function toPosixPath(value: string): string { + return value.split(path.sep).join('/') +} + +function writeFileIfChanged(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + try { + if (fs.readFileSync(filePath, 'utf8') === content) return + } catch { + // Generated file does not exist yet. + } + fs.writeFileSync(filePath, content) +} + +function hash(value: string): string { + return crypto.createHash('sha1').update(value).digest('hex').slice(0, 10) +} + +function safeFileName(value: string): string { + return value.replace(/[^a-zA-Z0-9_.-]+/g, '-') +} + +function normalizeImportPath(filePath: string): string { + const normalized = toPosixPath(filePath) + return normalized.startsWith('.') ? normalized : `./${normalized}` +} + +function manifestPath(projectRoot: string): string { + return path.join(projectRoot, MANIFEST_RELATIVE_PATH) +} + +function errorPrefix(projectRoot: string, filePath: string): string { + return `Voltra dynamic live activities manifest at ${toPosixPath(path.relative(projectRoot, filePath))}` +} + +export class MissingVoltraLiveActivitiesManifestError extends Error { + constructor({ projectRoot, filePath }: { projectRoot: string; filePath: string }) { + super( + `Missing Voltra dynamic live activities manifest at ${toPosixPath( + path.relative(projectRoot, filePath) + )}. Run Expo prebuild for ios to generate it.` + ) + this.name = 'MissingVoltraLiveActivitiesManifestError' + } +} + +export class InvalidVoltraLiveActivitiesManifestError extends Error { + constructor({ projectRoot, filePath, reason }: { projectRoot: string; filePath: string; reason: string }) { + super(`${errorPrefix(projectRoot, filePath)} is invalid: ${reason}`) + this.name = 'InvalidVoltraLiveActivitiesManifestError' + } +} + +export class DuplicateVoltraLiveActivityError extends Error { + constructor({ + projectRoot, + filePath, + definitionId, + }: { + projectRoot: string + filePath: string + definitionId: string + }) { + super( + `${errorPrefix( + projectRoot, + filePath + )} contains duplicate live activity id "${definitionId}". Live Activity ids must be unique within the Dynamic Live Activity manifest.` + ) + this.name = 'DuplicateVoltraLiveActivityError' + } +} + +function validateManifest(projectRoot: string, filePath: string, raw: unknown): DynamicLiveActivityManifest { + const invalid = (reason: string): never => { + throw new InvalidVoltraLiveActivitiesManifestError({ projectRoot, filePath, reason }) + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) invalid('manifest root must be a JSON object') + const manifest = raw as Record + if (manifest.version !== 1) invalid(`expected version 1, received ${JSON.stringify(manifest.version)}`) + if (manifest.platform !== 'ios') invalid(`expected platform "ios", received ${JSON.stringify(manifest.platform)}`) + if (!Array.isArray(manifest.liveActivities)) invalid('liveActivities must be an array') + + const liveActivities: DynamicLiveActivityManifestDefinition[] = [] + const ids = new Set() + for (const [index, value] of (manifest.liveActivities as unknown[]).entries()) { + if (!value || typeof value !== 'object' || Array.isArray(value)) + invalid(`liveActivities[${index}] must be an object with id and entry`) + const definition = value as Record + if (typeof definition.id !== 'string' || !definition.id.trim()) + invalid(`liveActivities[${index}].id must be a non-empty string`) + const id = definition.id as string + if (!/^[A-Za-z0-9_]+$/.test(id)) + invalid(`liveActivities[${index}].id must contain only alphanumeric characters and underscores`) + if (ids.has(id)) throw new DuplicateVoltraLiveActivityError({ projectRoot, filePath, definitionId: id }) + ids.add(id) + let entry = '' + try { + entry = validateWidgetEntry(definition.entry, id, projectRoot) + } catch (error) { + invalid(error instanceof Error ? error.message : String(error)) + } + liveActivities.push({ id, entry }) + } + return { version: 1, platform: 'ios', liveActivities } +} + +function createGeneratedEntry(projectRoot: string, generatedRoot: string, definition: RegisteredVoltraLiveActivity) { + const entryRoot = path.join(generatedRoot, 'live-activities') + const entryPath = path.join(entryRoot, `ios-${safeFileName(definition.id)}-${hash(definition.entry)}.js`) + const entryImportPath = normalizeImportPath(path.relative(entryRoot, path.join(projectRoot, definition.entry))) + const content = [ + `import { renderLiveActivityToJson } from '@use-voltra/ios'`, + `import LiveActivity from ${JSON.stringify(entryImportPath)}`, + '', + 'if (typeof LiveActivity !== "function") {', + ` throw new Error(${JSON.stringify( + `Voltra Dynamic Live Activity "${definition.id}" at "${definition.entry}" is missing a default function export.` + )})`, + '}', + '', + 'function parseJSONInput(input, label) {', + ' if (typeof input !== "string") return input || {}', + ' if (!input) return {}', + ' try { return JSON.parse(input) } catch (error) {', + ` throw new Error(\`Voltra Dynamic Live Activity "${definition.id}" failed to parse \${label}: \${error instanceof Error ? error.message : String(error)}\`)`, + ' }', + '}', + '', + 'export function render(propsJSON, environmentJSON) {', + ' const props = parseJSONInput(propsJSON, "propsJSON")', + ' const environment = parseJSONInput(environmentJSON, "environmentJSON")', + ' if (environment.date != null) environment.date = new Date(environment.date)', + ' return JSON.stringify(renderLiveActivityToJson(LiveActivity(props, environment)))', + '}', + '', + 'globalThis.__voltraDynamicLiveActivities = globalThis.__voltraDynamicLiveActivities || {}', + `globalThis.__voltraDynamicLiveActivities[${JSON.stringify(definition.id)}] = { render }`, + '', + 'export default render', + '', + ].join('\n') + writeFileIfChanged(entryPath, content) + return { + generatedEntryPath: entryPath, + generatedEntryRelativePath: toPosixPath(path.relative(projectRoot, entryPath)), + } +} + +function createBarrel(generatedRoot: string, definitions: RegisteredVoltraLiveActivity[]) { + const barrelPath = path.join(generatedRoot, 'live-activity-hot-reload.ios.js') + const imports = definitions.map( + (definition) => + `import ${JSON.stringify(normalizeImportPath(path.relative(generatedRoot, definition.generatedEntryPath)))}` + ) + writeFileIfChanged( + barrelPath, + [ + '// AUTO-GENERATED - do not edit. Side-effect imports that place manifest-declared Voltra Dynamic Live Activities in', + '// the host app dependency graph so Metro Fast Refresh drives dev hot reload of definitions.', + ...imports, + '', + ].join('\n') + ) +} + +export function createLiveActivityRegistry({ + projectRoot = process.cwd(), +}: { projectRoot?: string } = {}): LiveActivityRegistry { + const filePath = manifestPath(projectRoot) + const generatedRoot = path.join(projectRoot, '.voltra', 'metro') + let error: Error | null = null + let definitions: RegisteredVoltraLiveActivity[] = [] + if (!fs.existsSync(filePath)) { + error = new MissingVoltraLiveActivitiesManifestError({ projectRoot, filePath }) + } else { + try { + const manifest = validateManifest(projectRoot, filePath, JSON.parse(fs.readFileSync(filePath, 'utf8'))) + definitions = manifest.liveActivities.map((definition) => { + const base: RegisteredVoltraLiveActivity = { + ...definition, + platform: 'ios', + manifestPath: filePath, + generatedEntryPath: '', + generatedEntryRelativePath: '', + } + return { ...base, ...createGeneratedEntry(projectRoot, generatedRoot, base) } + }) + } catch (caught) { + error = caught instanceof Error ? caught : new Error(String(caught)) + } + } + createBarrel(generatedRoot, definitions) + return { + projectRoot, + getLiveActivity(definitionId) { + if (error) throw error + return definitions.find((definition) => definition.id === definitionId) || null + }, + isReady() { + return true + }, + listLiveActivities() { + if (error) throw error + return [...definitions] + }, + close() {}, + } +} diff --git a/packages/metro/src/widgetRegistry.node.test.ts b/packages/metro/src/widgetRegistry.node.test.ts index c7f9a000..fb0fb02a 100644 --- a/packages/metro/src/widgetRegistry.node.test.ts +++ b/packages/metro/src/widgetRegistry.node.test.ts @@ -9,6 +9,7 @@ import { createElement } from 'react' import { bundleWidgets } from './bundleWidgets.ts' import { createVoltraMiddleware } from './createVoltraMiddleware.ts' +import { createLiveActivityRegistry } from './liveActivityRegistry.ts' import { createWidgetRegistry } from './widgetRegistry.ts' const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..') @@ -223,6 +224,57 @@ describe('@use-voltra/metro manifest registry', () => { registry.close() } }) + + test('keeps Dynamic Live Activities in a separate registry when IDs collide with widgets', () => { + const { projectRoot, cleanup } = makeTempProject({ + '.voltra/manifest.ios.json': JSON.stringify({ + version: 1, + platform: 'ios', + widgets: [{ id: 'order', entry: 'widgets/order.js' }], + }), + '.voltra/manifest.ios.live-activities.json': JSON.stringify({ + version: 1, + platform: 'ios', + liveActivities: [{ id: 'order', entry: 'live-activities/order.js' }], + }), + 'widgets/order.js': 'export default function OrderWidget() { return null }\n', + 'live-activities/order.js': 'export default function OrderLiveActivity() { return {} }\n', + }) + cleanups.push(cleanup) + const widgets = createWidgetRegistry({ projectRoot }) + const liveActivities = createLiveActivityRegistry({ projectRoot }) + try { + const widget = widgets.getWidget('ios', 'order') + const liveActivity = liveActivities.getLiveActivity('order') + assert.ok(widget) + assert.ok(liveActivity) + assert.notEqual(widget.generatedEntryPath, liveActivity.generatedEntryPath) + const generated = fs.readFileSync(liveActivity.generatedEntryPath, 'utf8') + assert.match(generated, /renderLiveActivityToJson/) + assert.match(generated, /new Date\(environment.date\)/) + assert.match(generated, /__voltraDynamicLiveActivities/) + assert.match(generated, /LiveActivity\(props, environment\)/) + } finally { + widgets.close() + liveActivities.close() + } + }) + + test('reports malformed Dynamic Live Activity manifests with collection-specific errors', () => { + const { projectRoot, cleanup } = makeTempProject({ + '.voltra/manifest.ios.live-activities.json': JSON.stringify({ version: 1, platform: 'ios', widgets: [] }), + }) + cleanups.push(cleanup) + const registry = createLiveActivityRegistry({ projectRoot }) + try { + assert.throws( + () => registry.listLiveActivities(), + /dynamic live activities manifest.*liveActivities must be an array/i + ) + } finally { + registry.close() + } + }) }) describe('@use-voltra/metro middleware and bundling', () => { @@ -298,6 +350,46 @@ describe('@use-voltra/metro middleware and bundling', () => { } }) + test('routes colliding Dynamic Live Activity bundles through their dedicated endpoint', () => { + const { projectRoot, cleanup } = makeTempProject({ + '.voltra/manifest.ios.json': JSON.stringify({ + version: 1, + platform: 'ios', + widgets: [{ id: 'order', entry: 'widgets/order.js' }], + }), + '.voltra/manifest.ios.live-activities.json': JSON.stringify({ + version: 1, + platform: 'ios', + liveActivities: [{ id: 'order', entry: 'live-activities/order.js' }], + }), + 'widgets/order.js': 'export default function OrderWidget() { return null }\n', + 'live-activities/order.js': 'export default function OrderLiveActivity() { return {} }\n', + }) + cleanups.push(cleanup) + const registry = createWidgetRegistry({ projectRoot }) + const liveActivityRegistry = createLiveActivityRegistry({ projectRoot }) + const calls: string[] = [] + const middleware = createVoltraMiddleware({ + registry, + liveActivityRegistry, + widgetMetro: { + middleware(req: { url: string }) { + calls.push(req.url) + }, + }, + }) + const response = { writeHead() {}, end() {} } + try { + middleware({ url: '/live-activities/order.bundle?platform=ios' }, response, () => {}) + assert.equal(calls.length, 1) + assert.match(calls[0], /bundleEntry=.*live-activities/) + assert.match(calls[0], /platform=ios/) + } finally { + registry.close() + liveActivityRegistry.close() + } + }) + test('bakes no bundles for an empty manifest', async () => { const { projectRoot, cleanup } = makeTempProject({ '.voltra/manifest.ios.json': JSON.stringify( @@ -385,4 +477,41 @@ describe('@use-voltra/metro middleware and bundling', () => { assert.deepEqual(fs.readdirSync(outDir), ['voltra-widget-home.bundle']) }) + + test('bakes Dynamic Live Activity bundles with their dedicated release prefix', async () => { + const { projectRoot, cleanup } = makeTempProject({ + '.voltra/manifest.ios.json': JSON.stringify({ + version: 1, + platform: 'ios', + widgets: [{ id: 'order', entry: 'widgets/order.js' }], + }), + '.voltra/manifest.ios.live-activities.json': JSON.stringify({ + version: 1, + platform: 'ios', + liveActivities: [{ id: 'order', entry: 'live-activities/order.js' }], + }), + 'widgets/order.js': 'export default function OrderWidget() { return null }\n', + 'live-activities/order.js': 'export default function OrderLiveActivity() { return {} }\n', + }) + cleanups.push(cleanup) + const originalLoad = (Module as any)._load + mock.method(Module as any, '_load', function (request: string, parent: unknown, isMain: boolean) { + if (request === 'metro') return { runBuild: async () => ({ code: 'bundle' }) } + if (request === 'metro-config') + return { + loadConfig: async () => ({ resolver: {}, watchFolders: [], serializer: {}, transformer: {}, server: {} }), + getDefaultConfig: async () => ({ + resolver: {}, + watchFolders: [], + serializer: {}, + transformer: {}, + server: {}, + }), + } + return originalLoad.call(this, request, parent, isMain) + }) + const outDir = path.join(projectRoot, 'dist', 'widgets') + await bundleWidgets({ projectRoot, outDir, platform: 'ios' }) + assert.deepEqual(fs.readdirSync(outDir).sort(), ['voltra-live-activity-order.bundle', 'voltra-widget-order.bundle']) + }) }) From fa3f2fcb210970cfa4e1e06b9cfe82af3aae59fc Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 21:11:46 +0200 Subject: [PATCH 05/24] feat(ios): generate dynamic live activity scaffolding --- packages/ios-client/expo-plugin/src/index.ts | 2 + .../expo-plugin/src/ios-widget/files/index.ts | 15 +- .../ios-widget/files/manifest.node.test.ts | 49 ++++- .../src/ios-widget/files/manifest.ts | 44 +++- .../src/ios-widget/files/swift.node.test.ts | 31 +++ .../expo-plugin/src/ios-widget/files/swift.ts | 199 +++++++++++++++++- .../expo-plugin/src/ios-widget/index.ts | 14 +- .../xcode/applyXcodeChanges.node.test.ts | 31 +++ .../src/ios-widget/xcode/buildPhases.ts | 13 ++ .../expo-plugin/src/ios-widget/xcode/index.ts | 10 +- .../ios-client/expo-plugin/src/ios/index.ts | 1 + packages/ios-client/expo-plugin/src/types.ts | 1 + 12 files changed, 389 insertions(+), 21 deletions(-) diff --git a/packages/ios-client/expo-plugin/src/index.ts b/packages/ios-client/expo-plugin/src/index.ts index 3f52f369..131bd17e 100644 --- a/packages/ios-client/expo-plugin/src/index.ts +++ b/packages/ios-client/expo-plugin/src/index.ts @@ -41,6 +41,7 @@ const withVoltraIos: VoltraIosConfigPlugin = (config, props = {}) => { groupIdentifier: props.groupIdentifier, widgetIds: props.widgets && props.widgets.length > 0 ? props.widgets.map((w) => w.id) : undefined, widgets: props.widgets, + liveActivities: props.liveActivities, keychainGroup, }) @@ -49,6 +50,7 @@ const withVoltraIos: VoltraIosConfigPlugin = (config, props = {}) => { bundleIdentifier, deploymentTarget, widgets: props.widgets, + liveActivities: props.liveActivities, version, buildNumber, ...(props.groupIdentifier ? { groupIdentifier: props.groupIdentifier } : {}), 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..bd2c36c7 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,16 +2,17 @@ import { ConfigPlugin, withDangerousMod } from '@expo/config-plugins' import * as fs from 'fs' import * as path from 'path' -import type { IOSWidgetConfig } from '../../types' +import type { IOSDynamicLiveActivityConfig, IOSWidgetConfig } from '../../types' import { generateAssets } from './assets' import { generateEntitlements } from './entitlements' import { generateInfoPlist } from './infoPlist' -import { generateIOSDynamicWidgetsManifest } from './manifest' +import { generateIOSDynamicLiveActivitiesManifest, generateIOSDynamicWidgetsManifest } from './manifest' import { generateSwiftFiles } from './swift' export interface GenerateWidgetExtensionFilesProps { targetName: string widgets?: IOSWidgetConfig[] + liveActivities?: IOSDynamicLiveActivityConfig[] groupIdentifier?: string keychainGroup?: string version: string @@ -31,7 +32,7 @@ export interface GenerateWidgetExtensionFilesProps { * This should run before configureXcodeProject so the files exist when Xcode project is configured. */ export const generateWidgetExtensionFiles: ConfigPlugin = (config, props) => { - const { targetName, widgets, groupIdentifier, keychainGroup, version, buildNumber } = props + const { targetName, widgets, liveActivities, groupIdentifier, keychainGroup, version, buildNumber } = props return withDangerousMod(config, [ 'ios', @@ -59,6 +60,7 @@ export const generateWidgetExtensionFiles: ConfigPlugin): { projectRoot: string; cleanup: () => void } { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-ios-manifest-')) @@ -76,6 +81,48 @@ describe('createIOSDynamicWidgetsManifest', () => { }) }) +describe('Dynamic Live Activity manifest generation', () => { + it('uses the dedicated manifest and sorts declarations deterministically', () => { + const { projectRoot, cleanup } = makeTempProject({ + 'live-activities/alpha.tsx': 'export default function Alpha() {}', + 'live-activities/order.tsx': 'export default function Order() {}', + }) + + try { + expect( + createIOSDynamicLiveActivitiesManifest(projectRoot, [ + { id: 'order_finished', entry: './live-activities/order.tsx' }, + { id: 'alpha', entry: './live-activities/alpha.tsx' }, + ]) + ).toEqual({ + version: 1, + platform: 'ios', + liveActivities: [ + { id: 'alpha', entry: 'live-activities/alpha.tsx' }, + { id: 'order_finished', entry: 'live-activities/order.tsx' }, + ], + }) + } finally { + cleanup() + } + }) + + it('always rewrites an empty dedicated manifest to remove stale declarations', () => { + const { projectRoot, cleanup } = makeTempProject({ + '.voltra/manifest.ios.live-activities.json': '{"stale":true}\n', + }) + + try { + generateIOSDynamicLiveActivitiesManifest({ projectRoot, liveActivities: [] }) + expect(fs.readFileSync(path.join(projectRoot, '.voltra', 'manifest.ios.live-activities.json'), 'utf8')).toBe( + ['{', ' "version": 1,', ' "platform": "ios",', ' "liveActivities": []', '}', ''].join('\n') + ) + } finally { + cleanup() + } + }) +}) + describe('generateIOSDynamicWidgetsManifest', () => { it('writes the iOS manifest with deterministic formatting', () => { const { projectRoot, cleanup } = makeTempProject({ diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/manifest.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/manifest.ts index c26ad1a2..4dee6197 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/manifest.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/manifest.ts @@ -1,17 +1,28 @@ import * as fs from 'fs' import * as path from 'path' -import { type DynamicWidgetManifest, logger, validateWidgetEntry } from '@use-voltra/expo-plugin' +import { + type DynamicLiveActivityManifest, + type DynamicWidgetManifest, + logger, + validateWidgetEntry, +} from '@use-voltra/expo-plugin' -import type { IOSWidgetConfig } from '../../types' +import type { IOSDynamicLiveActivityConfig, IOSWidgetConfig } from '../../types' const MANIFEST_PATH = path.join('.voltra', 'manifest.ios.json') +const LIVE_ACTIVITIES_MANIFEST_PATH = path.join('.voltra', 'manifest.ios.live-activities.json') export interface GenerateIOSDynamicWidgetsManifestOptions { projectRoot: string widgets?: IOSWidgetConfig[] } +export interface GenerateIOSDynamicLiveActivitiesManifestOptions { + projectRoot: string + liveActivities?: IOSDynamicLiveActivityConfig[] +} + export function createIOSDynamicWidgetsManifest( projectRoot: string, widgets: IOSWidgetConfig[] @@ -36,6 +47,22 @@ export function createIOSDynamicWidgetsManifest( } } +export function createIOSDynamicLiveActivitiesManifest( + projectRoot: string, + liveActivities: IOSDynamicLiveActivityConfig[] +): DynamicLiveActivityManifest { + return { + version: 1, + platform: 'ios', + liveActivities: [...liveActivities] + .sort((left, right) => left.id.localeCompare(right.id)) + .map((liveActivity) => ({ + id: liveActivity.id, + entry: validateWidgetEntry(liveActivity.entry, liveActivity.id, projectRoot), + })), + } +} + export function generateIOSDynamicWidgetsManifest(options: GenerateIOSDynamicWidgetsManifestOptions): void { const { projectRoot, widgets } = options const manifestPath = path.join(projectRoot, MANIFEST_PATH) @@ -45,3 +72,16 @@ export function generateIOSDynamicWidgetsManifest(options: GenerateIOSDynamicWid fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) logger.info(`Generated ${MANIFEST_PATH}`) } + +/** Writes the dedicated Dynamic Live Activity manifest on every prebuild, including when empty. */ +export function generateIOSDynamicLiveActivitiesManifest( + options: GenerateIOSDynamicLiveActivitiesManifestOptions +): void { + const { projectRoot, liveActivities } = options + const manifestPath = path.join(projectRoot, LIVE_ACTIVITIES_MANIFEST_PATH) + const manifest = createIOSDynamicLiveActivitiesManifest(projectRoot, liveActivities ?? []) + + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }) + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + logger.info(`Generated ${LIVE_ACTIVITIES_MANIFEST_PATH}`) +} diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts index d82bbf61..283dd62a 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts @@ -74,6 +74,37 @@ describe('generateWidgetBundleSwift — Dynamic Widget dispatch', () => { }) }) +describe('Dynamic Live Activity Swift generation', () => { + const liveActivities = [ + { id: 'order_finished', entry: './live-activities/order-finished.tsx' }, + { id: 'driver_arrived', entry: './live-activities/driver-arrived.tsx' }, + ] + + it('creates distinct ActivityKit types, configurations, and catalog entries', () => { + const types = __test__.generateDynamicLiveActivityTypesSwift(liveActivities) + const configurations = __test__.generateDynamicLiveActivitiesSwift(liveActivities) + const bundle = __test__.generateWidgetBundleSwift([], liveActivities) + + expect(types).toContain('VoltraOrderFinishedLiveActivityAttributes') + expect(types).toContain('VoltraDriverArrivedLiveActivityAttributes') + expect(types).toContain('public let name: String') + expect(types).toContain('public let deepLinkUrl: String?') + expect(types).toContain('public let props: [String: VoltraDynamicLiveActivityJSONValue]') + expect(types).toContain( + 'VoltraDriverArrivedLiveActivityAttributes.self, VoltraOrderFinishedLiveActivityAttributes.self' + ) + expect(configurations).toContain('VoltraDynamicLiveActivityRenderer.lockScreen(definitionId: "driver_arrived"') + expect(configurations).toContain('VoltraDynamicLiveActivityRenderer.dynamicIsland(definitionId: "order_finished"') + expect(bundle).toContain('VoltraDynamicLiveActivity_VoltraDriverArrivedLiveActivityAttributes()') + expect(bundle).toContain('VoltraDynamicLiveActivity_VoltraOrderFinishedLiveActivityAttributes()') + }) + + it('keeps the legacy empty bundle unchanged when no Dynamic Live Activities are declared', () => { + expect(__test__.generateWidgetBundleSwift([], [])).toContain('import VoltraWidget') + expect(__test__.generateWidgetBundleSwift([], [])).not.toContain('VoltraDynamicLiveActivity_') + }) +}) + describe('generateWidgetBundleSwift — AppIntent configuration', () => { const configurableWidget: DetectedIOSWidget = { id: 'IosWeatherWidget', 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 9da2fd6a..f20cb431 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 @@ -3,6 +3,7 @@ import * as fs from 'fs' import * as path from 'path' import { + getDynamicLiveActivityAttributesType, isWidgetLocalizedMap, logger, prerenderWidgetState, @@ -12,7 +13,7 @@ import { } from '@use-voltra/expo-plugin' import { DEFAULT_WIDGET_FAMILIES, WIDGET_FAMILY_MAP } from '../../constants' -import type { IOSWidgetConfig } from '../../types' +import type { IOSDynamicLiveActivityConfig, IOSWidgetConfig } from '../../types' import { VOLTRA_WIDGET_STRINGS_BASENAME } from '../../utils/fileDiscovery' import { detectClientRenderedWidgets, type DetectedIOSWidget } from '../clientRendered' import { prerenderClientRenderedWidgets } from '../clientRenderedPrerender' @@ -21,6 +22,7 @@ export interface GenerateSwiftFilesOptions { targetPath: string projectRoot: string widgets?: IOSWidgetConfig[] + liveActivities?: IOSDynamicLiveActivityConfig[] } type RenderWidgetToString = (variants: unknown) => string @@ -37,7 +39,7 @@ type RenderWidgetToString = (variants: unknown) => string * - VoltraWidgetBundle.swift (widget bundle definition) */ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Promise { - const { targetPath, projectRoot, widgets } = options + const { targetPath, projectRoot, widgets, liveActivities } = options // Dynamic import keeps the plugin CommonJS-compatible while resolving the current package entry. const serverModuleId = '@use-voltra/ios/server' @@ -76,12 +78,24 @@ export async function generateSwiftFiles(options: GenerateSwiftFilesOptions): Pr // Generate the widget bundle Swift file const widgetBundleContent = - detectedWidgets.length > 0 ? generateWidgetBundleSwift(detectedWidgets) : generateDefaultWidgetBundleSwift() + detectedWidgets.length > 0 || (liveActivities?.length ?? 0) > 0 + ? generateWidgetBundleSwift(detectedWidgets, liveActivities ?? []) + : generateDefaultWidgetBundleSwift() const widgetBundlePath = path.join(targetPath, 'VoltraWidgetBundle.swift') fs.writeFileSync(widgetBundlePath, widgetBundleContent) logger.info(`Generated VoltraWidgetBundle.swift with ${widgets?.length ?? 0} home screen widgets`) + + fs.writeFileSync( + path.join(targetPath, 'VoltraDynamicLiveActivityTypes.swift'), + generateDynamicLiveActivityTypesSwift(liveActivities ?? []) + ) + fs.writeFileSync( + path.join(targetPath, 'VoltraDynamicLiveActivities.swift'), + generateDynamicLiveActivitiesSwift(liveActivities ?? []) + ) + logger.info(`Generated Dynamic Live Activity Swift scaffolding with ${liveActivities?.length ?? 0} definition(s)`) } const GENERATED_INITIAL_STATE_LOCALE_HELPER = dedent` @@ -443,7 +457,10 @@ function generateClientAppIntentWidgetCode(widget: DetectedIOSWidget): string { /** * Generates the VoltraWidgetBundle.swift file content with configured widgets */ -function generateWidgetBundleSwift(widgets: DetectedIOSWidget[]): string { +function generateWidgetBundleSwift( + widgets: DetectedIOSWidget[], + liveActivities: IOSDynamicLiveActivityConfig[] = [] +): string { // Generate widget structs const widgetStructs = widgets.map((w) => generateWidgetStruct(w)).join('\n\n') @@ -457,7 +474,15 @@ function generateWidgetBundleSwift(widgets: DetectedIOSWidget[]): string { .map((w) => `VoltraWidget_${w.id}()`) .join('\n ')}\n }` : '' - const widgetInstances = [plainInstances, appIntentInstances].filter(Boolean).join('\n ') + const dynamicLiveActivityInstances = [...liveActivities] + .sort((left, right) => left.id.localeCompare(right.id)) + .map((liveActivity) => `VoltraDynamicLiveActivity_${getDynamicLiveActivityAttributesType(liveActivity.id)}()`) + .join('\n ') + const widgetInstances = [plainInstances, appIntentInstances, dynamicLiveActivityInstances] + .filter(Boolean) + .join('\n ') + const widgetSectionTitle = + liveActivities.length > 0 ? 'Home Screen Widgets and Dynamic Live Activities' : 'Home Screen Widgets' const needsFoundation = widgets.some(widgetUsesGalleryLocalization) const foundationImport = needsFoundation ? 'import Foundation\n' : '' @@ -481,7 +506,7 @@ function generateWidgetBundleSwift(widgets: DetectedIOSWidget[]): string { // Live Activity (with Watch/CarPlay support) VoltraWidget() - // Home Screen Widgets + // ${widgetSectionTitle} ${widgetInstances} } } @@ -493,8 +518,8 @@ function generateWidgetBundleSwift(widgets: DetectedIOSWidget[]): string { } /** - * Generates the VoltraWidgetBundle.swift file content when no widgets are configured - * (only Live Activities) + * Generates the VoltraWidgetBundle.swift file content when no Home Screen widgets or Dynamic + * Live Activities are configured. Keep this legacy output stable for existing projects. */ function generateDefaultWidgetBundleSwift(): string { return dedent` @@ -519,6 +544,162 @@ function generateDefaultWidgetBundleSwift(): string { ` } +/** Generates the Dynamic Live Activity types shared by the app and extension targets. */ +function generateDynamicLiveActivityTypesSwift(liveActivities: IOSDynamicLiveActivityConfig[]): string { + const definitions = [...liveActivities].sort((left, right) => left.id.localeCompare(right.id)) + const catalogEntries = definitions + .map((liveActivity) => `${getDynamicLiveActivityAttributesType(liveActivity.id)}.self`) + .join(', ') + const typeDefinitions = definitions.map(generateDynamicLiveActivitySwift).map(indentGeneratedSwift).join('\n\n') + + const header = dedent` + // + // VoltraDynamicLiveActivityTypes.swift + // + // Auto-generated by Voltra config plugin. Do not edit. + // + + import ActivityKit + import Foundation + + /// The generic dynamic state shared by every generated Dynamic Live Activity type. + public struct VoltraDynamicLiveActivityContentState: Codable, Hashable { + public let props: [String: VoltraDynamicLiveActivityJSONValue] + + public init(props: [String: VoltraDynamicLiveActivityJSONValue]) { + self.props = props + } + } + + public indirect enum VoltraDynamicLiveActivityJSONValue: Codable, Hashable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([VoltraDynamicLiveActivityJSONValue]) + case object([String: VoltraDynamicLiveActivityJSONValue]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([VoltraDynamicLiveActivityJSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: VoltraDynamicLiveActivityJSONValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Expected a JSON-compatible value") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case let .bool(value): try container.encode(value) + case let .number(value): try container.encode(value) + case let .string(value): try container.encode(value) + case let .array(value): try container.encode(value) + case let .object(value): try container.encode(value) + } + } + } + + /// Type-erased metadata for the Dynamic Live Activity types bundled into this release. + public protocol VoltraDynamicLiveActivityDefinition: ActivityAttributes where ContentState == VoltraDynamicLiveActivityContentState { + static var definitionId: String { get } + static var attributesTypeName: String { get } + } + + public enum VoltraDynamicLiveActivityCatalog { + public static let definitions: [any VoltraDynamicLiveActivityDefinition.Type] = [${catalogEntries}] + + public static func contains(_ definitionId: String) -> Bool { + definitions.contains { $0.definitionId == definitionId } + } + } + + ` + return [header.trim(), typeDefinitions.trim()].filter(Boolean).join('\n\n') +} + +function generateDynamicLiveActivitySwift(liveActivity: IOSDynamicLiveActivityConfig): string { + const attributesType = getDynamicLiveActivityAttributesType(liveActivity.id) + const definitionId = escapeForSwiftStringLiteral(liveActivity.id) + + return dedent` + public struct ${attributesType}: ActivityAttributes { + public typealias ContentState = VoltraDynamicLiveActivityContentState + + public let name: String + public let deepLinkUrl: String? + + public init(name: String, deepLinkUrl: String? = nil) { + self.name = name + self.deepLinkUrl = deepLinkUrl + } + } + + extension ${attributesType}: VoltraDynamicLiveActivityDefinition { + public static let definitionId = "${definitionId}" + public static let attributesTypeName = "${attributesType}" + } + + ` +} + +/** Generates extension-only ActivityConfiguration declarations for Dynamic Live Activities. */ +function generateDynamicLiveActivitiesSwift(liveActivities: IOSDynamicLiveActivityConfig[]): string { + const definitions = [...liveActivities].sort((left, right) => left.id.localeCompare(right.id)) + const configurations = definitions + .map((liveActivity) => { + const attributesType = getDynamicLiveActivityAttributesType(liveActivity.id) + const definitionId = escapeForSwiftStringLiteral(liveActivity.id) + return dedent` + public struct VoltraDynamicLiveActivity_${attributesType}: Widget { + public init() {} + + public var body: some WidgetConfiguration { + ActivityConfiguration(for: ${attributesType}.self) { context in + VoltraDynamicLiveActivityRenderer.lockScreen(definitionId: "${definitionId}", context: context) + } dynamicIsland: { context in + VoltraDynamicLiveActivityRenderer.dynamicIsland(definitionId: "${definitionId}", context: context) + } + } + } + ` + }) + .map(indentGeneratedSwift) + .join('\n\n') + + const header = dedent` + // + // VoltraDynamicLiveActivities.swift + // + // Auto-generated by Voltra config plugin. Do not edit. + // + + import ActivityKit + import SwiftUI + import WidgetKit + import VoltraWidget + + ` + return [header.trim(), configurations.trim()].filter(Boolean).join('\n\n') +} + +function indentGeneratedSwift(source: string): string { + const lines = source.trim().split('\n') + const indent = Math.min(...lines.filter(Boolean).map((line) => line.match(/^\s*/)?.[0].length ?? 0)) + return lines.map((line) => line.slice(indent)).join('\n') +} + // ============================================================================ // Initial States // ============================================================================ @@ -624,4 +805,6 @@ function getSwiftRawStringDelimiter(str: string): string { export const __test__ = { generateInitialStatesSwift, generateWidgetBundleSwift, + generateDynamicLiveActivityTypesSwift, + generateDynamicLiveActivitiesSwift, } diff --git a/packages/ios-client/expo-plugin/src/ios-widget/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/index.ts index 0d940e63..06ffe3d2 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/index.ts @@ -1,6 +1,6 @@ import { ConfigPlugin, withPlugins } from '@expo/config-plugins' -import type { IOSWidgetConfig } from '../types' +import type { IOSDynamicLiveActivityConfig, IOSWidgetConfig } from '../types' import { configureEasBuild } from './eas' import { generateWidgetExtensionFiles } from './files' import { withFonts } from './fonts' @@ -13,6 +13,7 @@ export interface WithIOSProps { bundleIdentifier: string deploymentTarget: string widgets?: IOSWidgetConfig[] + liveActivities?: IOSDynamicLiveActivityConfig[] groupIdentifier?: string keychainGroup?: string fonts?: string[] @@ -42,6 +43,7 @@ export const withIOS: ConfigPlugin = (config, props) => { bundleIdentifier, deploymentTarget, widgets, + liveActivities, groupIdentifier, keychainGroup, fonts, @@ -54,7 +56,10 @@ export const withIOS: ConfigPlugin = (config, props) => { ...(fonts && fonts.length > 0 ? [[withFonts, { fonts, targetName }] as [ConfigPlugin, any]] : []), // 2. Configure Xcode project (creates the target - must run before fonts mod executes) - [configureXcodeProject, { targetName, bundleIdentifier, deploymentTarget, widgets, version, buildNumber }], + [ + configureXcodeProject, + { targetName, bundleIdentifier, deploymentTarget, widgets, liveActivities, version, buildNumber }, + ], // 3. Configure Podfile for widget extension target [configurePodfile, { targetName }], @@ -66,7 +71,10 @@ export const withIOS: ConfigPlugin = (config, props) => { [configureEasBuild, { targetName, bundleIdentifier, groupIdentifier }], // 6. Generate widget extension files (dangerous mod should run before plist patchers) - [generateWidgetExtensionFiles, { targetName, widgets, groupIdentifier, keychainGroup, version, buildNumber }], + [ + generateWidgetExtensionFiles, + { targetName, widgets, liveActivities, groupIdentifier, keychainGroup, version, buildNumber }, + ], ] return withPlugins(config, plugins) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts index 27edf1b9..2b830f09 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts @@ -34,6 +34,37 @@ function useDeterministicUuids(project: any): void { } describe('applyXcodeChanges — fresh Expo project (fixture a)', () => { + it('adds the release bundler and shared ActivityKit types when only Dynamic Live Activities exist', () => { + const project = loadFixtureProject('fresh.pbxproj') + useDeterministicUuids(project) + + applyXcodeChanges( + project, + { + ...PROPS, + liveActivities: [{ id: 'order_finished', entry: './live-activities/order-finished.tsx' }], + }, + { ...WIDGET_FILES, swiftFiles: [...WIDGET_FILES.swiftFiles, 'VoltraDynamicLiveActivityTypes.swift'] }, + true + ) + + const objects = project.hash.project.objects + const widgetTarget = project.findTargetKey(PROPS.targetName) + const shellPhases = objects.PBXShellScriptBuildPhase || {} + expect( + objects.PBXNativeTarget[widgetTarget].buildPhases.some((entry: any) => + String(shellPhases[String(entry.value).split(' ')[0]]?.name).includes('Bundle Voltra Dynamic Widgets') + ) + ).toBe(true) + + const typeRefs = Object.entries(objects.PBXFileReference).filter( + ([key, reference]: [string, any]) => + !key.endsWith('_comment') && JSON.stringify(reference).includes('VoltraDynamicLiveActivityTypes.swift') + ) + expect(typeRefs).toHaveLength(1) + expect(() => assertPbxConsistency(project)).not.toThrow() + }) + it('produces a consistent project', () => { const project = loadFixtureProject('fresh.pbxproj') useDeterministicUuids(project) 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 a5fd6e65..df57565d 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 @@ -112,6 +112,8 @@ export interface EnsureBuildPhasesOptions { } widgetFiles: IOSWidgetExtensionFiles mainTargetUuid?: string + /** Generated ActivityKit types compiled into both the extension and the app. */ + mainSwiftFiles?: string[] } /** @@ -191,6 +193,17 @@ export function ensureBuildPhases(xcodeProject: XcodeProject, options: EnsureBui ensureBuildPhaseFiles(xcodeProject, sourcesPhase, [...swiftFiles, ...intentFiles], targetName) } + if (options.mainSwiftFiles && options.mainSwiftFiles.length > 0) { + let mainSourcesPhase = findTargetPhaseByType(xcodeProject, mainTargetUuid, 'PBXSourcesBuildPhase') + if (!mainSourcesPhase) { + xcodeProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', mainTargetUuid, folderType, buildPath) + mainSourcesPhase = findTargetPhaseByType(xcodeProject, mainTargetUuid, 'PBXSourcesBuildPhase') + } + if (mainSourcesPhase) { + ensureBuildPhaseFiles(xcodeProject, mainSourcesPhase, options.mainSwiftFiles, targetName) + } + } + // Copy files build phase (embed extension into main app) — the embed phase is matched purely by // its `dstSubfolderSpec == 13` semantics, both before and after creation let copyFilesPhase = findExistingEmbedExtensionsPhase(xcodeProject, mainTargetUuid) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts index acca2eef..a1f77116 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts @@ -1,7 +1,7 @@ import { ConfigPlugin, withXcodeProject, XcodeProject } from '@expo/config-plugins' import * as path from 'path' -import type { IOSWidgetConfig, IOSWidgetExtensionFiles } from '../../types' +import type { IOSDynamicLiveActivityConfig, IOSWidgetConfig, IOSWidgetExtensionFiles } from '../../types' import { getIOSWidgetExtensionFiles } from '../../utils/fileDiscovery' import { detectClientRenderedWidgets } from '../clientRendered' import { ensureBuildPhases, ensureWidgetBundleScriptPhase } from './buildPhases' @@ -20,6 +20,7 @@ export interface ConfigureXcodeProjectProps { /** App build number; becomes the widget's CURRENT_PROJECT_VERSION. */ buildNumber?: string widgets?: IOSWidgetConfig[] + liveActivities?: IOSDynamicLiveActivityConfig[] } /** @@ -103,9 +104,10 @@ export function applyXcodeChanges( productFile, widgetFiles, mainTargetUuid: xcodeProject.getFirstTarget().uuid, + mainSwiftFiles: (props.liveActivities?.length ?? 0) > 0 ? ['VoltraDynamicLiveActivityTypes.swift'] : undefined, }) - if (hasClientRenderedWidgets) { + if (hasClientRenderedWidgets || (props.liveActivities?.length ?? 0) > 0) { ensureWidgetBundleScriptPhase(xcodeProject, targetUuid) } @@ -128,7 +130,7 @@ export function applyXcodeChanges( * mutation to {@link applyXcodeChanges}. */ export const configureXcodeProject: ConfigPlugin = (config, props) => { - const { targetName, widgets } = props + const { targetName, widgets, liveActivities } = props return withXcodeProject(config, (config) => { if (config.modRequest.introspect) { @@ -147,7 +149,7 @@ export const configureXcodeProject: ConfigPlugin = ( const targetPath = path.join(platformProjectRoot, targetName) const widgetFiles = getIOSWidgetExtensionFiles(targetPath, targetName) - applyXcodeChanges(xcodeProject, props, widgetFiles, hasClientRenderedWidgets) + applyXcodeChanges(xcodeProject, props, widgetFiles, hasClientRenderedWidgets || (liveActivities?.length ?? 0) > 0) return config }) diff --git a/packages/ios-client/expo-plugin/src/ios/index.ts b/packages/ios-client/expo-plugin/src/ios/index.ts index f0ed6f14..a56d2119 100644 --- a/packages/ios-client/expo-plugin/src/ios/index.ts +++ b/packages/ios-client/expo-plugin/src/ios/index.ts @@ -8,6 +8,7 @@ export interface IOSConfigProps { groupIdentifier?: string widgetIds?: string[] widgets?: import('../types').IOSWidgetConfig[] + liveActivities?: import('../types').IOSDynamicLiveActivityConfig[] keychainGroup?: string } diff --git a/packages/ios-client/expo-plugin/src/types.ts b/packages/ios-client/expo-plugin/src/types.ts index bc54fa39..e72a70c1 100644 --- a/packages/ios-client/expo-plugin/src/types.ts +++ b/packages/ios-client/expo-plugin/src/types.ts @@ -124,6 +124,7 @@ export interface IOSWidgetExtensionPluginProps { bundleIdentifier: string deploymentTarget: string widgets?: IOSWidgetConfig[] + liveActivities?: IOSDynamicLiveActivityConfig[] groupIdentifier?: string keychainGroup?: string fonts?: string[] From d362a88c2c8319ec84576255be4086708ed9dab5 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 21:18:42 +0200 Subject: [PATCH 06/24] feat(ios): render dynamic live activities on device --- .../src/ios-widget/files/swift.node.test.ts | 4 +- .../expo-plugin/src/ios-widget/files/swift.ts | 79 ++---- .../ios/shared/VoltraJSRenderer.swift | 98 +++++-- .../DynamicLiveActivityPropsCodec.swift | 13 + ...oltraDynamicLiveActivityBundleSource.swift | 66 +++++ .../VoltraDynamicLiveActivityRenderer.swift | 242 ++++++++++++++++++ .../VoltraDynamicLiveActivityTypes.swift | 80 ++++++ 7 files changed, 504 insertions(+), 78 deletions(-) create mode 100644 packages/ios-client/ios/shared/dynamic-live-activity/DynamicLiveActivityPropsCodec.swift create mode 100644 packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityBundleSource.swift create mode 100644 packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift create mode 100644 packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts index 283dd62a..f1eb2345 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts @@ -89,12 +89,14 @@ describe('Dynamic Live Activity Swift generation', () => { expect(types).toContain('VoltraDriverArrivedLiveActivityAttributes') expect(types).toContain('public let name: String') expect(types).toContain('public let deepLinkUrl: String?') - expect(types).toContain('public let props: [String: VoltraDynamicLiveActivityJSONValue]') + expect(types).toContain('import VoltraWidget') + expect(types).toContain('VoltraDynamicLiveActivityCatalog: VoltraDynamicLiveActivityCatalogLookup') expect(types).toContain( 'VoltraDriverArrivedLiveActivityAttributes.self, VoltraOrderFinishedLiveActivityAttributes.self' ) expect(configurations).toContain('VoltraDynamicLiveActivityRenderer.lockScreen(definitionId: "driver_arrived"') expect(configurations).toContain('VoltraDynamicLiveActivityRenderer.dynamicIsland(definitionId: "order_finished"') + expect(configurations).toContain('.supplementalActivityFamilies([.small, .medium])') expect(bundle).toContain('VoltraDynamicLiveActivity_VoltraDriverArrivedLiveActivityAttributes()') expect(bundle).toContain('VoltraDynamicLiveActivity_VoltraOrderFinishedLiveActivityAttributes()') }) 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 f20cb431..e459ec50 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 @@ -562,62 +562,13 @@ function generateDynamicLiveActivityTypesSwift(liveActivities: IOSDynamicLiveAct import ActivityKit import Foundation - /// The generic dynamic state shared by every generated Dynamic Live Activity type. - public struct VoltraDynamicLiveActivityContentState: Codable, Hashable { - public let props: [String: VoltraDynamicLiveActivityJSONValue] - - public init(props: [String: VoltraDynamicLiveActivityJSONValue]) { - self.props = props - } - } - - public indirect enum VoltraDynamicLiveActivityJSONValue: Codable, Hashable { - case null - case bool(Bool) - case number(Double) - case string(String) - case array([VoltraDynamicLiveActivityJSONValue]) - case object([String: VoltraDynamicLiveActivityJSONValue]) - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let value = try? container.decode(Bool.self) { - self = .bool(value) - } else if let value = try? container.decode(Double.self) { - self = .number(value) - } else if let value = try? container.decode(String.self) { - self = .string(value) - } else if let value = try? container.decode([VoltraDynamicLiveActivityJSONValue].self) { - self = .array(value) - } else if let value = try? container.decode([String: VoltraDynamicLiveActivityJSONValue].self) { - self = .object(value) - } else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Expected a JSON-compatible value") - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .null: try container.encodeNil() - case let .bool(value): try container.encode(value) - case let .number(value): try container.encode(value) - case let .string(value): try container.encode(value) - case let .array(value): try container.encode(value) - case let .object(value): try container.encode(value) - } - } - } - - /// Type-erased metadata for the Dynamic Live Activity types bundled into this release. - public protocol VoltraDynamicLiveActivityDefinition: ActivityAttributes where ContentState == VoltraDynamicLiveActivityContentState { - static var definitionId: String { get } - static var attributesTypeName: String { get } - } + #if canImport(VoltraWidget) + import VoltraWidget + #else + import Voltra + #endif - public enum VoltraDynamicLiveActivityCatalog { + public enum VoltraDynamicLiveActivityCatalog: VoltraDynamicLiveActivityCatalogLookup { public static let definitions: [any VoltraDynamicLiveActivityDefinition.Type] = [${catalogEntries}] public static func contains(_ definitionId: String) -> Bool { @@ -666,6 +617,24 @@ function generateDynamicLiveActivitiesSwift(liveActivities: IOSDynamicLiveActivi public init() {} public var body: some WidgetConfiguration { + if #available(iOS 18.0, *) { + return adaptiveConfig() + } else { + return defaultConfig() + } + } + + @available(iOS 18.0, *) + private func adaptiveConfig() -> some WidgetConfiguration { + ActivityConfiguration(for: ${attributesType}.self) { context in + VoltraDynamicLiveActivityRenderer.lockScreen(definitionId: "${definitionId}", context: context) + } dynamicIsland: { context in + VoltraDynamicLiveActivityRenderer.dynamicIsland(definitionId: "${definitionId}", context: context) + } + .supplementalActivityFamilies([.small, .medium]) + } + + private func defaultConfig() -> some WidgetConfiguration { ActivityConfiguration(for: ${attributesType}.self) { context in VoltraDynamicLiveActivityRenderer.lockScreen(definitionId: "${definitionId}", context: context) } dynamicIsland: { context in diff --git a/packages/ios-client/ios/shared/VoltraJSRenderer.swift b/packages/ios-client/ios/shared/VoltraJSRenderer.swift index 1a1a2212..85807eb2 100644 --- a/packages/ios-client/ios/shared/VoltraJSRenderer.swift +++ b/packages/ios-client/ios/shared/VoltraJSRenderer.swift @@ -30,6 +30,22 @@ public enum VoltraJSRenderer { /// Idempotent: re-evaluating the same widget overwrites the captured exports — used /// by dev-mode hot-reload (always-refetch policy). public static func evaluateBundle(source: String, widgetId: String) -> Bool { + evaluateBundle(source: source, id: widgetId, registryName: "__voltraWidgets", kind: "widget") + } + + /// Evaluate a Dynamic Live Activity bundle in the shared JSContext. Its exports + /// are captured separately from Dynamic Widgets so both collections may use the + /// same definition ID without overwriting one another. + public static func evaluateLiveActivityBundle(source: String, definitionId: String) -> Bool { + evaluateBundle( + source: source, + id: definitionId, + registryName: "__voltraDynamicLiveActivities", + kind: "live activity" + ) + } + + private static func evaluateBundle(source: String, id: String, registryName: String, kind: String) -> Bool { lock.lock() defer { lock.unlock() } @@ -38,7 +54,7 @@ public enum VoltraJSRenderer { return false } - let escapedId = jsStringLiteral(widgetId) + let escapedId = jsStringLiteral(id) // Metro emits the bundle's entry invocation as `__r();` near the // end of the file (before the sourcemap/sourceURL comments). The entry id is NOT // always 0 — when Metro serves multiple widget bundles from the same process, it @@ -50,34 +66,34 @@ public enum VoltraJSRenderer { let wrapped = """ \(source) ;(function () { - if (!globalThis.__voltraWidgets) { globalThis.__voltraWidgets = {}; } - globalThis.__voltraWidgets[\(escapedId)] = __r(\(entryModuleId)); + if (!globalThis.\(registryName)) { globalThis.\(registryName) = {}; } + globalThis.\(registryName)[\(escapedId)] = __r(\(entryModuleId)); })(); """ ctx.exception = nil ctx.evaluateScript(wrapped) if let message = exceptionMessage(ctx) { - VoltraLogger.widget.error("[\(TAG)] Bundle eval failed for widgetId=\(widgetId): \(message)") + VoltraLogger.widget.error("[\(TAG)] Bundle eval failed for \(kind) id=\(id): \(message)") return false } // Verify the bootstrap captured the exports correctly guard - let registry = ctx.objectForKeyedSubscript("__voltraWidgets"), + let registry = ctx.objectForKeyedSubscript(registryName), !registry.isUndefined, - let widget = registry.objectForKeyedSubscript(widgetId), - !widget.isUndefined, - let renderFn = widget.objectForKeyedSubscript("render"), + let entry = registry.objectForKeyedSubscript(id), + !entry.isUndefined, + let renderFn = entry.objectForKeyedSubscript("render"), renderFn.isObject, renderFn.objectForKeyedSubscript("call") != nil else { - VoltraLogger.widget.error("[\(TAG)] Bundle evaluated but did not expose render() for widgetId=\(widgetId)") + VoltraLogger.widget.error("[\(TAG)] Bundle evaluated but did not expose render() for \(kind) id=\(id)") return false } _ = renderFn - VoltraLogger.widget.info("[\(TAG)] Bundle evaluated for widgetId=\(widgetId) (\(source.count) chars)") + VoltraLogger.widget.info("[\(TAG)] Bundle evaluated for \(kind) id=\(id) (\(source.count) chars)") return true } @@ -90,11 +106,24 @@ public enum VoltraJSRenderer { /// evaluation never ran. The View calls this before `render()` so rendering never depends on /// which process evaluated the bundle. public static func ensureEvaluated(widgetId: String, source: String) -> Bool { + ensureEvaluated(source: source, id: widgetId, registryName: "__voltraWidgets", kind: "widget") + } + + public static func ensureLiveActivityEvaluated(definitionId: String, source: String) -> Bool { + ensureEvaluated( + source: source, + id: definitionId, + registryName: "__voltraDynamicLiveActivities", + kind: "live activity" + ) + } + + private static func ensureEvaluated(source: String, id: String, registryName: String, kind: String) -> Bool { lock.lock() let alreadyEvaluated = _context? - .objectForKeyedSubscript("__voltraWidgets")? - .objectForKeyedSubscript(widgetId)? + .objectForKeyedSubscript(registryName)? + .objectForKeyedSubscript(id)? .objectForKeyedSubscript("render")? .isObject ?? false lock.unlock() @@ -102,7 +131,7 @@ public enum VoltraJSRenderer { if alreadyEvaluated { return true } - return evaluateBundle(source: source, widgetId: widgetId) + return evaluateBundle(source: source, id: id, registryName: registryName, kind: kind) } /// Invoke the previously-evaluated widget's `render(propsJSON, envJSON)` function and @@ -114,39 +143,64 @@ public enum VoltraJSRenderer { widgetId: String, propsJSON: String, envJSON: String + ) -> String? { + render(id: widgetId, propsJSON: propsJSON, envJSON: envJSON, registryName: "__voltraWidgets", kind: "widget") + } + + /// Render a Dynamic Live Activity from its separately registered definition. + public static func renderLiveActivity( + definitionId: String, + propsJSON: String, + envJSON: String + ) -> String? { + render( + id: definitionId, + propsJSON: propsJSON, + envJSON: envJSON, + registryName: "__voltraDynamicLiveActivities", + kind: "live activity" + ) + } + + private static func render( + id: String, + propsJSON: String, + envJSON: String, + registryName: String, + kind: String ) -> String? { lock.lock() defer { lock.unlock() } guard let ctx = _context else { - VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)): no JSContext (call evaluateBundle first)") + VoltraLogger.widget.error("[\(TAG)] render \(kind) id=\(id): no JSContext (call evaluateBundle first)") return nil } guard - let registry = ctx.objectForKeyedSubscript("__voltraWidgets"), + let registry = ctx.objectForKeyedSubscript(registryName), !registry.isUndefined, - let widget = registry.objectForKeyedSubscript(widgetId), - !widget.isUndefined, - let renderFn = widget.objectForKeyedSubscript("render"), + let entry = registry.objectForKeyedSubscript(id), + !entry.isUndefined, + let renderFn = entry.objectForKeyedSubscript("render"), renderFn.isObject else { - VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)): no captured render() — bundle not evaluated?") + VoltraLogger.widget.error("[\(TAG)] render \(kind) id=\(id): no captured render() — bundle not evaluated?") return nil } ctx.exception = nil guard let result = renderFn.call(withArguments: [propsJSON, envJSON]) else { - VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)): call returned nil") + VoltraLogger.widget.error("[\(TAG)] render \(kind) id=\(id): call returned nil") return nil } if let message = exceptionMessage(ctx) { - VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)) threw: \(message)") + VoltraLogger.widget.error("[\(TAG)] render \(kind) id=\(id) threw: \(message)") return nil } guard result.isString else { - VoltraLogger.widget.error("[\(TAG)] render(\(widgetId)) did not return a string") + VoltraLogger.widget.error("[\(TAG)] render \(kind) id=\(id) did not return a string") return nil } return result.toString() diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/DynamicLiveActivityPropsCodec.swift b/packages/ios-client/ios/shared/dynamic-live-activity/DynamicLiveActivityPropsCodec.swift new file mode 100644 index 00000000..799ca934 --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/DynamicLiveActivityPropsCodec.swift @@ -0,0 +1,13 @@ +import Foundation + +enum DynamicLiveActivityPropsCodec { + static func encode(_ props: [String: VoltraDynamicLiveActivityJSONValue]) -> String? { + guard + let data = try? JSONEncoder().encode(props), + let json = String(data: data, encoding: .utf8) + else { + return nil + } + return json + } +} diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityBundleSource.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityBundleSource.swift new file mode 100644 index 00000000..16fb6fbf --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityBundleSource.swift @@ -0,0 +1,66 @@ +import Foundation + +enum VoltraDynamicLiveActivityBundleSource { + enum LoadError: LocalizedError { + case metroHTTP(Int) + case nonUTF8 + case bakedBundleNotFound(definitionId: String) + + var errorDescription: String? { + switch self { + case let .metroHTTP(status): + return "Metro HTTP \(status) while loading a Dynamic Live Activity bundle" + case .nonUTF8: + return "Dynamic Live Activity bundle was not UTF-8 text" + case let .bakedBundleNotFound(definitionId): + return "Production Dynamic Live Activity bundle missing for definitionId=\(definitionId)" + } + } + } + + static func load(definitionId: String) throws -> String { + #if DEBUG + return try loadFromMetro(definitionId: definitionId) + #else + return try loadFromBakedAsset(definitionId: definitionId) + #endif + } + + private static func loadFromMetro(definitionId: String) throws -> String { + let base = VoltraWidgetDefaults.devServerURL() ?? "http://localhost:8081" + guard let url = URL(string: "\(base)/voltra/live-activities/\(definitionId).bundle?platform=ios&dev=true") else { + throw LoadError.metroHTTP(-1) + } + let semaphore = DispatchSemaphore(value: 0) + var result: Result<(Data, URLResponse), Error>? + URLSession.shared.dataTask(with: url) { data, response, error in + if let error { + result = .failure(error) + } else if let data, let response { + result = .success((data, response)) + } else { + result = .failure(LoadError.metroHTTP(-1)) + } + semaphore.signal() + }.resume() + semaphore.wait() + let (data, response) = try result!.get() + if let httpResponse = response as? HTTPURLResponse, !(200 ... 299).contains(httpResponse.statusCode) { + throw LoadError.metroHTTP(httpResponse.statusCode) + } + guard let source = String(data: data, encoding: .utf8) else { + throw LoadError.nonUTF8 + } + return source + } + + private static func loadFromBakedAsset(definitionId: String) throws -> String { + guard + let url = Bundle.main.url(forResource: "voltra-live-activity-\(definitionId)", withExtension: "bundle"), + let source = try? String(contentsOf: url, encoding: .utf8) + else { + throw LoadError.bakedBundleNotFound(definitionId: definitionId) + } + return source + } +} diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift new file mode 100644 index 00000000..900b6716 --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift @@ -0,0 +1,242 @@ +import ActivityKit +import Foundation +import SwiftUI +import WidgetKit + +/// Renders generated Dynamic Live Activity configurations through their bundled +/// JavaScript definitions. Failures intentionally become `EmptyView`: a remote +/// ActivityKit start may already exist and a later update can recover it. +public enum VoltraDynamicLiveActivityRenderer { + public static func lockScreen( + definitionId: String, + context: ActivityViewContext + ) -> some View { + if #available(iOS 18.0, *) { + return VoltraDynamicLiveActivityAdaptiveLockScreenView(definitionId: definitionId, context: context) + } + return VoltraDynamicLiveActivityLockScreenView(definitionId: definitionId, context: context) + } + + public static func dynamicIsland( + definitionId: String, + context: ActivityViewContext + ) -> DynamicIsland { + let content = resolve(definitionId: definitionId, context: context, activityFamily: nil) + let island = DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + content.view(for: .islandExpandedLeading, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + } + DynamicIslandExpandedRegion(.trailing) { + content.view(for: .islandExpandedTrailing, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + } + DynamicIslandExpandedRegion(.center) { + content.view(for: .islandExpandedCenter, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + } + DynamicIslandExpandedRegion(.bottom) { + content.view(for: .islandExpandedBottom, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + } + } compactLeading: { + content.view(for: .islandCompactLeading, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + } compactTrailing: { + content.view(for: .islandCompactTrailing, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + } minimal: { + content.view(for: .islandMinimal, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + } + + if let keylineTint = content.payload?.keylineTint, let color = JSColorParser.parse(keylineTint) { + return island.keylineTint(color) + } + return island + } + + fileprivate static func resolve( + definitionId: String, + context: ActivityViewContext, + activityFamily: String?, + colorScheme: ColorScheme? = nil, + locale: Locale = .current, + widgetRenderingMode: WidgetRenderingMode = .fullColor + ) -> VoltraDynamicLiveActivityResolvedContent { + guard let propsJSON = DynamicLiveActivityPropsCodec.encode(context.state.props) else { + logFailure(definitionId: definitionId, message: "Could not encode content-state props") + return .empty + } + let environmentJSON = VoltraDynamicLiveActivityEnvironmentBuilder.build( + date: Date(), + colorScheme: colorScheme, + locale: locale, + widgetRenderingMode: widgetRenderingMode, + isStale: context.isStale, + activityFamily: activityFamily + ) + + do { + let source = try VoltraDynamicLiveActivityBundleSource.load(definitionId: definitionId) + guard VoltraJSRenderer.ensureLiveActivityEvaluated(definitionId: definitionId, source: source) else { + logFailure(definitionId: definitionId, message: "Could not evaluate definition bundle") + return .empty + } + guard let renderedJSON = VoltraJSRenderer.renderLiveActivity( + definitionId: definitionId, + propsJSON: propsJSON, + envJSON: environmentJSON + ) else { + logFailure(definitionId: definitionId, message: "Definition render failed") + return .empty + } + let payload = try VoltraLiveActivityPayload(jsonString: renderedJSON) + return VoltraDynamicLiveActivityResolvedContent(payload: payload) + } catch { + logFailure(definitionId: definitionId, message: error.localizedDescription) + return .empty + } + } + + fileprivate static func logFailure(definitionId: String, message: String) { + // Task 07 will additionally persist this structured failure in the App Group queue. + VoltraLogger.activity.error("[DynamicLiveActivity] definitionId=\(definitionId) \(message)") + } +} + +private struct VoltraDynamicLiveActivityLockScreenView: View { + let definitionId: String + let context: ActivityViewContext + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.locale) private var locale + @Environment(\.widgetRenderingMode) private var widgetRenderingMode + + var body: some View { + let content = VoltraDynamicLiveActivityRenderer.resolve( + definitionId: definitionId, + context: context, + activityFamily: nil, + colorScheme: colorScheme, + locale: locale, + widgetRenderingMode: widgetRenderingMode + ) + if let tint = content.payload?.activityBackgroundTint, let color = JSColorParser.parse(tint) { + content.view(for: .lockScreen, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + .activityBackgroundTint(color) + } else { + content.view(for: .lockScreen, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + } + } +} + +@available(iOS 18.0, *) +private struct VoltraDynamicLiveActivityAdaptiveLockScreenView: View { + let definitionId: String + let context: ActivityViewContext + + @Environment(\.activityFamily) private var activityFamily + @Environment(\.colorScheme) private var colorScheme + @Environment(\.locale) private var locale + @Environment(\.widgetRenderingMode) private var widgetRenderingMode + + var body: some View { + let content = VoltraDynamicLiveActivityRenderer.resolve( + definitionId: definitionId, + context: context, + activityFamily: String(describing: activityFamily), + colorScheme: colorScheme, + locale: locale, + widgetRenderingMode: widgetRenderingMode + ) + if activityFamily == .small { + smallFamilyContent(content: content) + } else { + lockScreenContent(content: content) + } + } + + @ViewBuilder + private func smallFamilyContent(content: VoltraDynamicLiveActivityResolvedContent) -> some View { + if content.hasContent(for: .supplementalActivityFamiliesSmall) { + content.view( + for: .supplementalActivityFamiliesSmall, + activityId: context.activityID, + deepLink: context.attributes.deepLinkUrl + ) + } else if content.hasContent(for: .islandCompactLeading) || content.hasContent(for: .islandCompactTrailing) { + HStack(spacing: 0) { + content.view(for: .islandCompactLeading, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + Spacer() + content.view(for: .islandCompactTrailing, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + } + .frame(maxWidth: .infinity) + } + } + + @ViewBuilder + private func lockScreenContent(content: VoltraDynamicLiveActivityResolvedContent) -> some View { + if let tint = content.payload?.activityBackgroundTint, let color = JSColorParser.parse(tint) { + content.view(for: .lockScreen, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + .activityBackgroundTint(color) + } else { + content.view(for: .lockScreen, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + } + } +} + +private struct VoltraDynamicLiveActivityResolvedContent { + static let empty = VoltraDynamicLiveActivityResolvedContent(payload: nil) + + let payload: VoltraLiveActivityPayload? + + func hasContent(for region: VoltraRegion) -> Bool { + !(payload?.regions[region] ?? []).isEmpty + } + + @ViewBuilder + func view(for region: VoltraRegion, activityId: String, deepLink: String?) -> some View { + if let nodes = payload?.regions[region], !nodes.isEmpty { + let root: VoltraNode = nodes.count == 1 ? nodes[0] : .array(nodes) + Voltra(root: root, activityId: activityId) + .voltraIfLet(deepLink) { view, url in view.widgetURL(URL(string: url)) } + } + } +} + +private enum VoltraDynamicLiveActivityEnvironmentBuilder { + static func build( + date: Date, + colorScheme: ColorScheme?, + locale: Locale, + widgetRenderingMode: WidgetRenderingMode, + isStale: Bool, + activityFamily: String? + ) -> String { + #if DEBUG + let isDev = true + let metroURL: String? = VoltraWidgetDefaults.devServerURL() ?? "http://localhost:8081" + #else + let isDev = false + let metroURL: String? = nil + #endif + let build: [String: Any] = [ + "isDev": isDev, + "metroUrl": metroURL as Any, + "appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown", + "voltraVersion": "1.4.1", + ] + var environment: [String: Any] = [ + "date": Int(date.timeIntervalSince1970 * 1000), + "colorScheme": String(describing: colorScheme ?? .light), + "locale": locale.identifier, + "widgetRenderingMode": String(describing: widgetRenderingMode), + "build": build, + "isStale": isStale, + ] + if let activityFamily { + environment["activityFamily"] = activityFamily + } + guard + let data = try? JSONSerialization.data(withJSONObject: environment), + let json = String(data: data, encoding: .utf8) + else { + return "{}" + } + return json + } +} diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift new file mode 100644 index 00000000..37821f8e --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift @@ -0,0 +1,80 @@ +import ActivityKit +import Foundation + +/// The JSON-compatible value type used by Dynamic Live Activity props. +/// +/// This deliberately models only values that can cross the ActivityKit and +/// JavaScript boundaries without a definition-specific schema. +public indirect enum VoltraDynamicLiveActivityJSONValue: Codable, Hashable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([VoltraDynamicLiveActivityJSONValue]) + case object([String: VoltraDynamicLiveActivityJSONValue]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([VoltraDynamicLiveActivityJSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: VoltraDynamicLiveActivityJSONValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected a JSON-compatible value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case let .bool(value): + try container.encode(value) + case let .number(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + } + } +} + +/// The generic state shared by every generated Dynamic Live Activity type. +/// Every ActivityKit update replaces the complete props record. +public struct VoltraDynamicLiveActivityContentState: Codable, Hashable { + public let props: [String: VoltraDynamicLiveActivityJSONValue] + + public init(props: [String: VoltraDynamicLiveActivityJSONValue]) { + self.props = props + } +} + +/// Metadata and static attributes supplied by each generated definition. +public protocol VoltraDynamicLiveActivityDefinition: ActivityAttributes where ContentState == VoltraDynamicLiveActivityContentState { + static var definitionId: String { get } + static var attributesTypeName: String { get } + + var name: String { get } + var deepLinkUrl: String? { get } +} + +/// Lets app-side lifecycle code check the generated catalog without coupling the +/// shared renderer to a particular generated file. +public protocol VoltraDynamicLiveActivityCatalogLookup { + static func contains(_ definitionId: String) -> Bool +} From 0b51dd528b05e546236b67599f4baa70be4e0681 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 21:26:13 +0200 Subject: [PATCH 07/24] feat(ios): add dynamic live activity lifecycle services --- .../src/ios-widget/files/swift.node.test.ts | 3 + .../expo-plugin/src/ios-widget/files/swift.ts | 66 ++++++++++++++ .../applyXcodeChanges.node.test.ts.snap | 62 +++++++------ .../expo-plugin/src/ios-widget/xcode/index.ts | 14 ++- packages/ios-client/ios/Package.swift | 2 + ...micLiveActivityPayloadValidatorTests.swift | 26 ++++++ packages/ios-client/ios/app/NativeVoltra.mm | 31 +++++++ .../ios/app/VoltraLiveActivityService.swift | 75 +++++++++++++++- .../ios-client/ios/app/VoltraModule.swift | 32 +++++++ .../ios-client/ios/app/VoltraModuleImpl.swift | 56 +++++++++++- .../VoltraDynamicLiveActivityOperations.swift | 88 +++++++++++++++++++ ...aDynamicLiveActivityPayloadValidator.swift | 49 +++++++++++ .../VoltraDynamicLiveActivityTypes.swift | 45 +++++++--- .../ios-client/src/native/NativeVoltra.ts | 2 + 14 files changed, 502 insertions(+), 49 deletions(-) create mode 100644 packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityPayloadValidatorTests.swift create mode 100644 packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift create mode 100644 packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts index f1eb2345..698ac424 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts @@ -94,6 +94,9 @@ describe('Dynamic Live Activity Swift generation', () => { expect(types).toContain( 'VoltraDriverArrivedLiveActivityAttributes.self, VoltraOrderFinishedLiveActivityAttributes.self' ) + expect(types).toContain('VoltraDynamicLiveActivityOperations.create(') + expect(types).toContain('VoltraDynamicLiveActivityOperations.update(') + expect(types).toContain('VoltraDynamicLiveActivityOperations.endAll(') expect(configurations).toContain('VoltraDynamicLiveActivityRenderer.lockScreen(definitionId: "driver_arrived"') expect(configurations).toContain('VoltraDynamicLiveActivityRenderer.dynamicIsland(definitionId: "order_finished"') expect(configurations).toContain('.supplementalActivityFamilies([.small, .medium])') 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 e459ec50..07be4b71 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 @@ -574,6 +574,72 @@ function generateDynamicLiveActivityTypesSwift(liveActivities: IOSDynamicLiveAct public static func contains(_ definitionId: String) -> Bool { definitions.contains { $0.definitionId == definitionId } } + + public static func create(_ request: VoltraDynamicLiveActivityCreateRequest) async throws -> Bool { + switch request.definitionId { +${definitions + .map( + (liveActivity) => ` case "${escapeForSwiftStringLiteral(liveActivity.id)}": + try await VoltraDynamicLiveActivityOperations.create(${getDynamicLiveActivityAttributesType( + liveActivity.id + )}.self, request: request) + return true` + ) + .join('\n')} + default: + return false + } + } + + public static func update(byName name: String, request: VoltraDynamicLiveActivityUpdateRequest) async throws -> Bool { +${definitions + .map( + (liveActivity) => + ` if await VoltraDynamicLiveActivityOperations.update(${getDynamicLiveActivityAttributesType( + liveActivity.id + )}.self, byName: name, request: request) { return true }` + ) + .join('\n')} + return false + } + + public static func end(byName name: String, dismissalPolicy: ActivityUIDismissalPolicy) async -> Bool { +${definitions + .map( + (liveActivity) => + ` if await VoltraDynamicLiveActivityOperations.end(${getDynamicLiveActivityAttributesType( + liveActivity.id + )}.self, byName: name, dismissalPolicy: dismissalPolicy) { return true }` + ) + .join('\n')} + return false + } + + public static func endAll(dismissalPolicy: ActivityUIDismissalPolicy) async { +${definitions + .map( + (liveActivity) => + ` await VoltraDynamicLiveActivityOperations.endAll(${getDynamicLiveActivityAttributesType( + liveActivity.id + )}.self, dismissalPolicy: dismissalPolicy)` + ) + .join('\n')} + } + + public static func activities() -> [VoltraDynamicLiveActivityReference] { + ${ + definitions.length === 0 + ? 'return []' + : `[${definitions + .map( + (liveActivity) => + `VoltraDynamicLiveActivityOperations.activities(${getDynamicLiveActivityAttributesType( + liveActivity.id + )}.self)` + ) + .join(', ')}].flatMap { $0 }` + } + } } ` diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap b/packages/ios-client/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap index fff2f9c8..9d545d29 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap @@ -13,11 +13,13 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit 13B07FBF1A68108700A75B9A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.swift */; }; 13B07FC11A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FC21A68108700A75B9A /* Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB61A68108700A75B9A /* Info.plist */; }; - F1D00000000000000000000F /* VoltraWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D000000000000000000008 /* VoltraWidget.swift */; }; - F1D000000000000000000010 /* VoltraWidgetInitialStates.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D000000000000000000009 /* VoltraWidgetInitialStates.swift */; }; + F1D000000000000000000010 /* VoltraWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D000000000000000000008 /* VoltraWidget.swift */; }; + F1D000000000000000000011 /* VoltraWidgetInitialStates.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D000000000000000000009 /* VoltraWidgetInitialStates.swift */; }; + F1D000000000000000000012 /* VoltraDynamicLiveActivityTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D00000000000000000000A /* VoltraDynamicLiveActivityTypes.swift */; }; + F1D000000000000000000013 /* VoltraDynamicLiveActivityTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D00000000000000000000A /* VoltraDynamicLiveActivityTypes.swift */; }; F1D000000000000000000006 /* VoltraWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F1D000000000000000000005 /* VoltraWidgetExtension.appex */; }; - F1D000000000000000000014 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F1D00000000000000000000C /* Assets.xcassets */; }; - F1D000000000000000000015 /* VoltraWidgets.strings in Resources */ = {isa = PBXBuildFile; fileRef = F1D00000000000000000000D /* VoltraWidgets.strings */; }; + F1D000000000000000000017 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F1D00000000000000000000D /* Assets.xcassets */; }; + F1D000000000000000000018 /* VoltraWidgets.strings in Resources */ = {isa = PBXBuildFile; fileRef = F1D00000000000000000000E /* VoltraWidgets.strings */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -28,10 +30,11 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit F1D000000000000000000005 /* VoltraWidgetExtension.appex */ = {isa = PBXFileReference; name = "VoltraWidgetExtension.appex"; path = "VoltraWidgetExtension.appex"; sourceTree = BUILT_PRODUCTS_DIR; fileEncoding = undefined; lastKnownFileType = undefined; explicitFileType = wrapper.app-extension; includeInIndex = 0; }; F1D000000000000000000008 /* VoltraWidget.swift */ = {isa = PBXFileReference; name = "VoltraWidget.swift"; path = "VoltraWidget.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; F1D000000000000000000009 /* VoltraWidgetInitialStates.swift */ = {isa = PBXFileReference; name = "VoltraWidgetInitialStates.swift"; path = "VoltraWidgetInitialStates.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; - F1D00000000000000000000A /* VoltraWidgetExtension.entitlements */ = {isa = PBXFileReference; name = "VoltraWidgetExtension.entitlements"; path = "VoltraWidgetExtension.entitlements"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; }; - F1D00000000000000000000B /* VoltraWidgetExtension-Info.plist */ = {isa = PBXFileReference; name = "VoltraWidgetExtension-Info.plist"; path = "VoltraWidgetExtension-Info.plist"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = text.plist.xml; explicitFileType = undefined; includeInIndex = 0; }; - F1D00000000000000000000C /* Assets.xcassets */ = {isa = PBXFileReference; name = "Assets.xcassets"; path = "Assets.xcassets"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = folder.assetcatalog; explicitFileType = undefined; includeInIndex = 0; }; - F1D00000000000000000000D /* VoltraWidgets.strings */ = {isa = PBXFileReference; name = "VoltraWidgets.strings"; path = "en.lproj/VoltraWidgets.strings"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = text.plist.strings; explicitFileType = undefined; includeInIndex = 0; }; + F1D00000000000000000000A /* VoltraDynamicLiveActivityTypes.swift */ = {isa = PBXFileReference; name = "VoltraDynamicLiveActivityTypes.swift"; path = "VoltraDynamicLiveActivityTypes.swift"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = sourcecode.swift; explicitFileType = undefined; includeInIndex = 0; }; + F1D00000000000000000000B /* VoltraWidgetExtension.entitlements */ = {isa = PBXFileReference; name = "VoltraWidgetExtension.entitlements"; path = "VoltraWidgetExtension.entitlements"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; }; + F1D00000000000000000000C /* VoltraWidgetExtension-Info.plist */ = {isa = PBXFileReference; name = "VoltraWidgetExtension-Info.plist"; path = "VoltraWidgetExtension-Info.plist"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = text.plist.xml; explicitFileType = undefined; includeInIndex = 0; }; + F1D00000000000000000000D /* Assets.xcassets */ = {isa = PBXFileReference; name = "Assets.xcassets"; path = "Assets.xcassets"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = folder.assetcatalog; explicitFileType = undefined; includeInIndex = 0; }; + F1D00000000000000000000E /* VoltraWidgets.strings */ = {isa = PBXFileReference; name = "VoltraWidgets.strings"; path = "en.lproj/VoltraWidgets.strings"; sourceTree = ""; fileEncoding = 4; lastKnownFileType = text.plist.strings; explicitFileType = undefined; includeInIndex = 0; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -42,7 +45,7 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit ); runOnlyForDeploymentPostprocessing = 0; }; - F1D000000000000000000012 /* Frameworks */ = { + F1D000000000000000000015 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( @@ -88,10 +91,11 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit children = ( F1D000000000000000000008 /* VoltraWidget.swift */, F1D000000000000000000009 /* VoltraWidgetInitialStates.swift */, - F1D00000000000000000000A /* VoltraWidgetExtension.entitlements */, - F1D00000000000000000000B /* VoltraWidgetExtension-Info.plist */, - F1D00000000000000000000C /* Assets.xcassets */, - F1D00000000000000000000D /* VoltraWidgets.strings */, + F1D00000000000000000000A /* VoltraDynamicLiveActivityTypes.swift */, + F1D00000000000000000000B /* VoltraWidgetExtension.entitlements */, + F1D00000000000000000000C /* VoltraWidgetExtension-Info.plist */, + F1D00000000000000000000D /* Assets.xcassets */, + F1D00000000000000000000E /* VoltraWidgets.strings */, ); name = VoltraWidgetExtension; path = VoltraWidgetExtension; @@ -107,12 +111,12 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, - F1D000000000000000000011 /* Embed Foundation Extensions */, + F1D000000000000000000014 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( - F1D000000000000000000016 /* PBXTargetDependency */, + F1D000000000000000000019 /* PBXTargetDependency */, ); name = voltraexample; productName = voltraexample; @@ -127,9 +131,9 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit buildConfigurationList = F1D000000000000000000002; productReference = F1D000000000000000000005; buildPhases = ( - F1D00000000000000000000E /* Sources */, - F1D000000000000000000012 /* Frameworks */, - F1D000000000000000000013 /* Resources */, + F1D00000000000000000000F /* Sources */, + F1D000000000000000000015 /* Frameworks */, + F1D000000000000000000016 /* Resources */, ); buildRules = ( ); @@ -181,12 +185,12 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit ); runOnlyForDeploymentPostprocessing = 0; }; - F1D000000000000000000013 /* Resources */ = { + F1D000000000000000000016 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - F1D000000000000000000014 /* Assets.xcassets in Resources */, - F1D000000000000000000015 /* VoltraWidgets.strings in Resources */, + F1D000000000000000000017 /* Assets.xcassets in Resources */, + F1D000000000000000000018 /* VoltraWidgets.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -198,15 +202,17 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit buildActionMask = 2147483647; files = ( 13B07FBF1A68108700A75B9A /* AppDelegate.swift in Sources */, + F1D000000000000000000013 /* VoltraDynamicLiveActivityTypes.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - F1D00000000000000000000E /* Sources */ = { + F1D00000000000000000000F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F1D00000000000000000000F /* VoltraWidget.swift in Sources */, - F1D000000000000000000010 /* VoltraWidgetInitialStates.swift in Sources */, + F1D000000000000000000010 /* VoltraWidget.swift in Sources */, + F1D000000000000000000011 /* VoltraWidgetInitialStates.swift in Sources */, + F1D000000000000000000012 /* VoltraDynamicLiveActivityTypes.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -320,7 +326,7 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit /* End XCConfigurationList section */ /* Begin PBXCopyFilesBuildPhase section */ - F1D000000000000000000011 /* Embed Foundation Extensions */ = { + F1D000000000000000000014 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; files = ( @@ -334,15 +340,15 @@ exports[`applyXcodeChanges — fresh Expo project (fixture a) matches the commit /* End PBXCopyFilesBuildPhase section */ /* Begin PBXTargetDependency section */ - F1D000000000000000000016 /* PBXTargetDependency */ = { + F1D000000000000000000019 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = F1D000000000000000000001 /* VoltraWidgetExtension */; - targetProxy = F1D000000000000000000017 /* PBXContainerItemProxy */; + targetProxy = F1D00000000000000000001A /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin PBXContainerItemProxy section */ - F1D000000000000000000017 /* PBXContainerItemProxy */ = { + F1D00000000000000000001A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; proxyType = 1; diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts index a1f77116..dc2c4ff0 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts @@ -43,6 +43,12 @@ export function applyXcodeChanges( ): void { const { targetName, bundleIdentifier, deploymentTarget, version, buildNumber } = props const groupName = 'Embed Foundation Extensions' + // The catalog is compiled into the app even for a legacy-only configuration. + // Keep it in the extension group too so both targets share one PBX file reference. + const effectiveWidgetFiles: IOSWidgetExtensionFiles = { + ...widgetFiles, + swiftFiles: Array.from(new Set([...widgetFiles.swiftFiles, 'VoltraDynamicLiveActivityTypes.swift'])), + } // Read main app target settings to synchronize code signing (per configuration). const mainAppSettings = getMainAppTargetSettings(xcodeProject) @@ -93,7 +99,7 @@ export function applyXcodeChanges( // reference them (the phases resolve files through the widget-scoped group). ensurePbxGroup(xcodeProject, { targetName, - widgetFiles, + widgetFiles: effectiveWidgetFiles, }) // Ensure build phases and their files. @@ -102,9 +108,11 @@ export function applyXcodeChanges( targetName, groupName, productFile, - widgetFiles, + widgetFiles: effectiveWidgetFiles, mainTargetUuid: xcodeProject.getFirstTarget().uuid, - mainSwiftFiles: (props.liveActivities?.length ?? 0) > 0 ? ['VoltraDynamicLiveActivityTypes.swift'] : undefined, + // The app-side lifecycle service always compiles against the generated catalog. + // An empty catalog keeps legacy-only apps source-compatible. + mainSwiftFiles: ['VoltraDynamicLiveActivityTypes.swift'], }) if (hasClientRenderedWidgets || (props.liveActivities?.length ?? 0) > 0) { diff --git a/packages/ios-client/ios/Package.swift b/packages/ios-client/ios/Package.swift index 451f7711..a5b575df 100644 --- a/packages/ios-client/ios/Package.swift +++ b/packages/ios-client/ios/Package.swift @@ -42,6 +42,8 @@ let package = Package( "DynamicWidgetPropsStore.swift", "DynamicWidgetRenderCoordinator.swift", "DynamicWidgetUpdater.swift", + "dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift", + "dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift", "JSONValue.swift", "VoltraConfig.swift", "VoltraConstants.swift", diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityPayloadValidatorTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityPayloadValidatorTests.swift new file mode 100644 index 00000000..a1a7748f --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityPayloadValidatorTests.swift @@ -0,0 +1,26 @@ +@testable import VoltraSharedCore +import XCTest + +final class DynamicLiveActivityPayloadValidatorTests: XCTestCase { + func testDecodesCompleteJSONCompatibleProps() throws { + let props = try VoltraDynamicLiveActivityPayloadValidator.decodeProps( + #"{"status":"delivering","items":[1,true,null],"address":{"city":"Warsaw"}}"# + ) + + XCTAssertEqual(props["status"], .string("delivering")) + XCTAssertEqual(props["items"], .array([.number(1), .bool(true), .null])) + XCTAssertEqual(props["address"], .object(["city": .string("Warsaw")])) + } + + func testRejectsPropsBeyondActivityKitFourKilobyteLimit() throws { + let props: [String: VoltraDynamicLiveActivityJSONValue] = ["message": .string(String(repeating: "x", count: 5000))] + + XCTAssertThrowsError( + try VoltraDynamicLiveActivityPayloadValidator.validate(name: "order-123", deepLinkUrl: nil, props: props) + ) { error in + guard case VoltraDynamicLiveActivityError.payloadTooLarge = error else { + return XCTFail("Expected payloadTooLarge, got \(error)") + } + } + } +} diff --git a/packages/ios-client/ios/app/NativeVoltra.mm b/packages/ios-client/ios/app/NativeVoltra.mm index 30fb94c0..cc6cc377 100644 --- a/packages/ios-client/ios/app/NativeVoltra.mm +++ b/packages/ios-client/ios/app/NativeVoltra.mm @@ -211,6 +211,37 @@ - (void)updateLiveActivity:(NSString *)activityId }]; } +- (void)startDynamicLiveActivity:(NSString *)definitionId + propsJson:(NSString *)propsJson + options:(JS::NativeVoltra::StartVoltraOptions &)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + StartVoltraOptions *opts = [StartVoltraOptions new]; + opts.activityName = options.activityName(); + opts.deepLinkUrl = options.deepLinkUrl(); + opts.channelId = options.channelId(); + if (auto v = options.staleDate()) opts.staleDate = @(v.value()); + if (auto v = options.relevanceScore()) opts.relevanceScore = @(v.value()); + [self.module startDynamicLiveActivity:definitionId propsJson:propsJson options:opts completion:^(NSString *activityId, NSError *error) { + if (error) { reject(@"startDynamicLiveActivity", error.localizedDescription, error); } else { resolve(activityId); } + }]; +} + +- (void)updateDynamicLiveActivity:(NSString *)activityId + propsJson:(NSString *)propsJson + options:(JS::NativeVoltra::UpdateVoltraOptions &)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + UpdateVoltraOptions *opts = [UpdateVoltraOptions new]; + if (auto v = options.staleDate()) opts.staleDate = @(v.value()); + if (auto v = options.relevanceScore()) opts.relevanceScore = @(v.value()); + [self.module updateDynamicLiveActivity:activityId propsJson:propsJson options:opts completion:^(NSError *error) { + if (error) { reject(@"updateDynamicLiveActivity", error.localizedDescription, error); } else { resolve(nil); } + }]; +} + - (void)endLiveActivity:(NSString *)activityId options:(JS::NativeVoltra::EndVoltraOptions &)options resolve:(RCTPromiseResolveBlock)resolve diff --git a/packages/ios-client/ios/app/VoltraLiveActivityService.swift b/packages/ios-client/ios/app/VoltraLiveActivityService.swift index dc347029..8e4d7bd2 100644 --- a/packages/ios-client/ios/app/VoltraLiveActivityService.swift +++ b/packages/ios-client/ios/app/VoltraLiveActivityService.swift @@ -122,7 +122,20 @@ public class VoltraLiveActivityService { /// Check if an activity with the given name exists across both types public func isActivityActive(name: String) -> Bool { - findActivity(byName: name) != nil + findActivity(byName: name) != nil || VoltraDynamicLiveActivityCatalog.activities().contains { $0.name == name } + } + + /// The unified list intentionally erases each engine's concrete attributes type. + public func getAllActivityReferences() -> [VoltraDynamicLiveActivityReference] { + guard Self.isSupported() else { return [] } + let legacy = getAllActivities().map { + VoltraDynamicLiveActivityReference(id: $0.id, name: $0.attributes.name, definitionId: "legacy") + } + return legacy + VoltraDynamicLiveActivityCatalog.activities() + } + + public func latestActivityId() -> String? { + getAllActivityReferences().last?.id } // MARK: - Create Operations @@ -202,6 +215,9 @@ public class VoltraLiveActivityService { request: UpdateActivityRequest ) async throws { guard let activity = findActivity(byName: name) else { + if VoltraDynamicLiveActivityCatalog.activities().contains(where: { $0.name == name }) { + throw VoltraLiveActivityError.rendererMismatch + } throw VoltraLiveActivityError.notFound } try await updateActivity(activity, request: request) @@ -231,10 +247,15 @@ public class VoltraLiveActivityService { byName name: String, dismissalPolicy: ActivityUIDismissalPolicy = .immediate ) async throws { - guard let activity = findActivity(byName: name) else { + if let activity = findActivity(byName: name) { + await endActivity(activity, dismissalPolicy: dismissalPolicy) + // Names can collide across engines after a remote start. Shared ending covers both. + _ = await VoltraDynamicLiveActivityCatalog.end(byName: name, dismissalPolicy: dismissalPolicy) + return + } + guard await VoltraDynamicLiveActivityCatalog.end(byName: name, dismissalPolicy: dismissalPolicy) else { throw VoltraLiveActivityError.notFound } - await endActivity(activity, dismissalPolicy: dismissalPolicy) } /// End all activities with the same name @@ -245,6 +266,7 @@ public class VoltraLiveActivityService { for activity in activities { await endActivity(activity) } + _ = await VoltraDynamicLiveActivityCatalog.end(byName: name, dismissalPolicy: .immediate) } /// End all Voltra Live Activities @@ -254,6 +276,52 @@ public class VoltraLiveActivityService { for activity in activities { await endActivity(activity) } + await VoltraDynamicLiveActivityCatalog.endAll(dismissalPolicy: .immediate) + } + + // MARK: - Dynamic operations + + public func createDynamicActivity(_ request: VoltraDynamicLiveActivityCreateRequest) async throws -> String { + guard Self.isSupported() else { throw VoltraLiveActivityError.unsupportedOS } + guard Self.areActivitiesEnabled() else { throw VoltraLiveActivityError.liveActivitiesNotEnabled } + guard VoltraDynamicLiveActivityCatalog.contains(request.definitionId) else { + throw VoltraDynamicLiveActivityError.unknownDefinition(request.definitionId) + } + do { + let source = try VoltraDynamicLiveActivityBundleSource.load(definitionId: request.definitionId) + guard VoltraJSRenderer.evaluateLiveActivityBundle(source: source, definitionId: request.definitionId) else { + throw VoltraDynamicLiveActivityError.resourceUnavailable( + NSError(domain: "VoltraDynamicLiveActivity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Dynamic Live Activity bundle could not be evaluated."]) + ) + } + } catch let error as VoltraDynamicLiveActivityError { + throw error + } catch { + throw VoltraDynamicLiveActivityError.resourceUnavailable(error) + } + try VoltraDynamicLiveActivityPayloadValidator.validate( + name: request.name, + deepLinkUrl: request.deepLinkUrl, + props: request.props + ) + if request.name.isEmpty == false { + try await endActivities(byName: request.name) + } + guard try await VoltraDynamicLiveActivityCatalog.create(request) else { + throw VoltraDynamicLiveActivityError.unknownDefinition(request.definitionId) + } + return request.name + } + + public func updateDynamicActivity(byName name: String, request: VoltraDynamicLiveActivityUpdateRequest) async throws { + guard Self.isSupported() else { throw VoltraLiveActivityError.unsupportedOS } + if findActivity(byName: name) != nil { + throw VoltraDynamicLiveActivityError.rendererMismatch + } + try VoltraDynamicLiveActivityPayloadValidator.validateContentState(request.props) + guard try await VoltraDynamicLiveActivityCatalog.update(byName: name, request: request) else { + throw VoltraLiveActivityError.notFound + } } // MARK: - Monitoring @@ -305,4 +373,5 @@ public enum VoltraLiveActivityError: Error { case unsupportedOS case notFound case liveActivitiesNotEnabled + case rendererMismatch } diff --git a/packages/ios-client/ios/app/VoltraModule.swift b/packages/ios-client/ios/app/VoltraModule.swift index ab1aa0d2..0e8b218a 100644 --- a/packages/ios-client/ios/app/VoltraModule.swift +++ b/packages/ios-client/ios/app/VoltraModule.swift @@ -4,6 +4,7 @@ public enum VoltraErrors: Error { case unsupportedOS case notFound case liveActivitiesNotEnabled + case rendererMismatch case unexpectedError(Error) } @@ -59,6 +60,37 @@ public enum VoltraErrors: Error { } } + @objc public func startDynamicLiveActivity( + _ definitionId: String, + propsJson: String, + options: StartVoltraOptions?, + completion: @escaping (String?, Error?) -> Void + ) { + Task { + do { + try completion(await impl.startDynamicLiveActivity(definitionId: definitionId, propsJson: propsJson, options: options), nil) + } catch { + completion(nil, error) + } + } + } + + @objc public func updateDynamicLiveActivity( + _ activityId: String, + propsJson: String, + options: UpdateVoltraOptions?, + completion: @escaping (Error?) -> Void + ) { + Task { + do { + try await impl.updateDynamicLiveActivity(activityId: activityId, propsJson: propsJson, options: options) + completion(nil) + } catch { + completion(error) + } + } + } + @objc public func endLiveActivity( _ activityId: String, options: EndVoltraOptions?, diff --git a/packages/ios-client/ios/app/VoltraModuleImpl.swift b/packages/ios-client/ios/app/VoltraModuleImpl.swift index 2aab439f..cf2a6fbc 100644 --- a/packages/ios-client/ios/app/VoltraModuleImpl.swift +++ b/packages/ios-client/ios/app/VoltraModuleImpl.swift @@ -182,6 +182,48 @@ public class VoltraModuleImpl { } } + func startDynamicLiveActivity(definitionId: String, propsJson: String, options: StartVoltraOptions?) async throws -> String { + guard #available(iOS 16.4, *) else { throw VoltraErrors.unsupportedOS } + let name = options?.activityName?.trimmingCharacters(in: .whitespacesAndNewlines) + let activityName = name?.isEmpty == false ? name! : UUID().uuidString + do { + let props = try VoltraDynamicLiveActivityPayloadValidator.decodeProps(propsJson) + let staleDate = options?.staleDate.map { Date(timeIntervalSince1970: $0.doubleValue / 1000.0) } + let pushType = try resolvePushType(channelId: options?.channelId) + let request = VoltraDynamicLiveActivityCreateRequest( + definitionId: definitionId, + name: activityName, + deepLinkUrl: options?.deepLinkUrl, + props: props, + staleDate: staleDate, + relevanceScore: options?.relevanceScore?.doubleValue ?? 0.0, + pushType: pushType + ) + return try await liveActivityService.createDynamicActivity(request) + } catch { + VoltraLogger.module.error("startDynamicLiveActivity failed: \(error)") + throw mapError(error) + } + } + + func updateDynamicLiveActivity(activityId: String, propsJson: String, options: UpdateVoltraOptions?) async throws { + guard #available(iOS 16.4, *) else { throw VoltraErrors.unsupportedOS } + do { + let props = try VoltraDynamicLiveActivityPayloadValidator.decodeProps(propsJson) + let staleDate = options?.staleDate.map { Date(timeIntervalSince1970: $0.doubleValue / 1000.0) } + try await liveActivityService.updateDynamicActivity( + byName: activityId, + request: VoltraDynamicLiveActivityUpdateRequest( + props: props, + staleDate: staleDate, + relevanceScore: options?.relevanceScore?.doubleValue ?? 0.0 + ) + ) + } catch { + throw mapError(error) + } + } + func endLiveActivity(activityId: String, options: EndVoltraOptions?) async throws { guard #available(iOS 16.4, *) else { throw VoltraErrors.unsupportedOS } @@ -202,12 +244,12 @@ public class VoltraModuleImpl { func getLatestVoltraActivityId() -> String? { guard #available(iOS 16.4, *) else { return nil } - return liveActivityService.getLatestActivity()?.id + return liveActivityService.latestActivityId() } func listVoltraActivityIds() -> [String] { guard #available(iOS 16.4, *) else { return [] } - return liveActivityService.getAllActivities().map(\.id) + return liveActivityService.getAllActivityReferences().map(\.id) } func isLiveActivityActive(name: String) -> Bool { @@ -342,6 +384,16 @@ public class VoltraModuleImpl { return VoltraErrors.liveActivitiesNotEnabled case .notFound: return VoltraErrors.notFound + case .rendererMismatch: + return VoltraErrors.rendererMismatch + } + } + if let dynamicError = error as? VoltraDynamicLiveActivityError { + switch dynamicError { + case .rendererMismatch: + return VoltraErrors.rendererMismatch + case .unknownDefinition, .payloadTooLarge, .resourceUnavailable: + return VoltraErrors.unexpectedError(error) } } return VoltraErrors.unexpectedError(error) diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift new file mode 100644 index 00000000..1b3d5d07 --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift @@ -0,0 +1,88 @@ +import ActivityKit +import Foundation + +public struct VoltraDynamicLiveActivityCreateRequest { + public let definitionId: String + public let name: String + public let deepLinkUrl: String? + public let props: [String: VoltraDynamicLiveActivityJSONValue] + public let staleDate: Date? + public let relevanceScore: Double + public let pushType: PushType? +} + +public struct VoltraDynamicLiveActivityUpdateRequest { + public let props: [String: VoltraDynamicLiveActivityJSONValue] + public let staleDate: Date? + public let relevanceScore: Double +} + +public struct VoltraDynamicLiveActivityReference: Hashable { + public let id: String + public let name: String + public let definitionId: String +} + +/// Generic ActivityKit operations called by the generated catalog. The catalog +/// supplies the concrete generated Attributes type without exposing it to the +/// legacy service or the React Native bridge. +public enum VoltraDynamicLiveActivityOperations { + public static func create( + _: Attributes.Type, + request: VoltraDynamicLiveActivityCreateRequest + ) async throws { + let attributes = Attributes(name: request.name, deepLinkUrl: request.deepLinkUrl) + let state = VoltraDynamicLiveActivityContentState(props: request.props) + _ = try Activity.request( + attributes: attributes, + content: ActivityContent(state: state, staleDate: request.staleDate, relevanceScore: request.relevanceScore), + pushType: request.pushType + ) + } + + public static func update( + _: Attributes.Type, + byName name: String, + request: VoltraDynamicLiveActivityUpdateRequest + ) async -> Bool { + let activities = Activity.activities.filter { $0.attributes.name == name } + for activity in activities { + // State is deliberately replaced in full; V1 does not merge props. + await activity.update(ActivityContent( + state: VoltraDynamicLiveActivityContentState(props: request.props), + staleDate: request.staleDate, + relevanceScore: request.relevanceScore + )) + } + return !activities.isEmpty + } + + public static func end( + _: Attributes.Type, + byName name: String, + dismissalPolicy: ActivityUIDismissalPolicy + ) async -> Bool { + let activities = Activity.activities.filter { $0.attributes.name == name } + for activity in activities { + await activity.end(ActivityContent(state: activity.content.state, staleDate: nil), dismissalPolicy: dismissalPolicy) + } + return !activities.isEmpty + } + + public static func endAll( + _: Attributes.Type, + dismissalPolicy: ActivityUIDismissalPolicy + ) async { + for activity in Activity.activities { + await activity.end(ActivityContent(state: activity.content.state, staleDate: nil), dismissalPolicy: dismissalPolicy) + } + } + + public static func activities( + _: Attributes.Type + ) -> [VoltraDynamicLiveActivityReference] { + Activity.activities.map { + VoltraDynamicLiveActivityReference(id: $0.id, name: $0.attributes.name, definitionId: Attributes.definitionId) + } + } +} diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift new file mode 100644 index 00000000..241e9eec --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Checks the portion of a Dynamic Live Activity payload ActivityKit stores. +/// This is intentionally uncompressed: ActivityKit's 4 KB limit applies to its +/// encoded attributes and content state, not Voltra's legacy rendered payload. +public enum VoltraDynamicLiveActivityPayloadValidator { + private struct Payload: Encodable { + let attributes: Attributes + let contentState: VoltraDynamicLiveActivityContentState + + enum CodingKeys: String, CodingKey { + case attributes + case contentState = "content-state" + } + } + + private struct Attributes: Encodable { + let name: String + let deepLinkUrl: String? + } + + public static func decodeProps(_ jsonString: String) throws -> [String: VoltraDynamicLiveActivityJSONValue] { + let data = Data(jsonString.utf8) + return try JSONDecoder().decode([String: VoltraDynamicLiveActivityJSONValue].self, from: data) + } + + public static func validate( + name: String, + deepLinkUrl: String?, + props: [String: VoltraDynamicLiveActivityJSONValue] + ) throws { + let payload = Payload( + attributes: Attributes(name: name, deepLinkUrl: deepLinkUrl), + contentState: VoltraDynamicLiveActivityContentState(props: props) + ) + try validateEncoded(payload) + } + + public static func validateContentState(_ props: [String: VoltraDynamicLiveActivityJSONValue]) throws { + try validateEncoded(VoltraDynamicLiveActivityContentState(props: props)) + } + + private static func validateEncoded(_ value: some Encodable) throws { + let size = try JSONEncoder().encode(value).count + guard size <= VoltraConstants.maxPayloadSizeBytes else { + throw VoltraDynamicLiveActivityError.payloadTooLarge(size: size) + } + } +} diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift index 37821f8e..78f2c89a 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift @@ -1,6 +1,9 @@ -import ActivityKit import Foundation +#if os(iOS) + import ActivityKit +#endif + /// The JSON-compatible value type used by Dynamic Live Activity props. /// /// This deliberately models only values that can cross the ActivityKit and @@ -64,17 +67,33 @@ public struct VoltraDynamicLiveActivityContentState: Codable, Hashable { } } -/// Metadata and static attributes supplied by each generated definition. -public protocol VoltraDynamicLiveActivityDefinition: ActivityAttributes where ContentState == VoltraDynamicLiveActivityContentState { - static var definitionId: String { get } - static var attributesTypeName: String { get } - - var name: String { get } - var deepLinkUrl: String? { get } +public enum VoltraDynamicLiveActivityError: Error { + case unknownDefinition(String) + case payloadTooLarge(size: Int) + case rendererMismatch + case resourceUnavailable(Error) } -/// Lets app-side lifecycle code check the generated catalog without coupling the -/// shared renderer to a particular generated file. -public protocol VoltraDynamicLiveActivityCatalogLookup { - static func contains(_ definitionId: String) -> Bool -} +// ActivityKit is unavailable to the macOS SwiftPM test target. The JSON props +// contract above remains independently testable there. +#if os(iOS) + /// Metadata and static attributes supplied by each generated definition. + public protocol VoltraDynamicLiveActivityDefinition: ActivityAttributes where ContentState == VoltraDynamicLiveActivityContentState { + static var definitionId: String { get } + static var attributesTypeName: String { get } + + var name: String { get } + var deepLinkUrl: String? { get } + } + + /// Lets app-side lifecycle code check the generated catalog without coupling the + /// shared renderer to a particular generated file. + public protocol VoltraDynamicLiveActivityCatalogLookup { + static func contains(_ definitionId: String) -> Bool + static func create(_ request: VoltraDynamicLiveActivityCreateRequest) async throws -> Bool + static func update(byName name: String, request: VoltraDynamicLiveActivityUpdateRequest) async throws -> Bool + static func end(byName name: String, dismissalPolicy: ActivityUIDismissalPolicy) async -> Bool + static func endAll(dismissalPolicy: ActivityUIDismissalPolicy) async + static func activities() -> [VoltraDynamicLiveActivityReference] + } +#endif diff --git a/packages/ios-client/src/native/NativeVoltra.ts b/packages/ios-client/src/native/NativeVoltra.ts index 2796ac92..5c1bef5a 100644 --- a/packages/ios-client/src/native/NativeVoltra.ts +++ b/packages/ios-client/src/native/NativeVoltra.ts @@ -91,6 +91,8 @@ export interface Spec extends TurboModule { readonly onActivityPushToStartTokenReceived: CodegenTypes.EventEmitter startLiveActivity(jsonString: string, options: StartVoltraOptions): Promise updateLiveActivity(activityId: string, jsonString: string, options: UpdateVoltraOptions): Promise + startDynamicLiveActivity(definitionId: string, propsJson: string, options: StartVoltraOptions): Promise + updateDynamicLiveActivity(activityId: string, propsJson: string, options: UpdateVoltraOptions): Promise endLiveActivity(activityId: string, options: EndVoltraOptions): Promise endAllLiveActivities(): Promise getLatestVoltraActivityId(): Promise From 6ef1044390c4a85cc025bb5e0456075799ef890d Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 21:32:48 +0200 Subject: [PATCH 08/24] feat(ios): observe dynamic live activities --- .../src/ios-widget/files/swift.node.test.ts | 9 ++ .../expo-plugin/src/ios-widget/files/swift.ts | 25 +++++ packages/ios-client/ios/app/NativeVoltra.mm | 10 ++ .../ios/app/VoltraLiveActivityManager.swift | 23 +++++ .../ios/app/VoltraLiveActivityService.swift | 32 +++++++ .../ios-client/ios/app/VoltraModule.swift | 11 +++ .../ios-client/ios/app/VoltraModuleImpl.swift | 12 +++ .../VoltraDynamicLiveActivityObserver.swift | 93 +++++++++++++++++++ .../VoltraDynamicLiveActivityOperations.swift | 12 +++ .../VoltraDynamicLiveActivityTypes.swift | 3 + .../ios-client/jest.dynamic-widget.config.js | 5 +- packages/ios-client/src/index.ts | 1 + .../ios-client/src/native/NativeVoltra.ts | 2 + .../enableDynamicLiveActivityHotReload.ts | 24 +++++ .../hotReload.node.test.ts | 50 ++++++++++ packages/metro/src/liveActivityRegistry.ts | 3 + .../metro/src/widgetRegistry.node.test.ts | 2 + 17 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityObserver.swift create mode 100644 packages/ios-client/src/utils/enableDynamicLiveActivityHotReload.ts create mode 100644 packages/ios-client/tests/dynamic-live-activity/hotReload.node.test.ts diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts index 698ac424..d1b73298 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts @@ -97,6 +97,15 @@ describe('Dynamic Live Activity Swift generation', () => { expect(types).toContain('VoltraDynamicLiveActivityOperations.create(') expect(types).toContain('VoltraDynamicLiveActivityOperations.update(') expect(types).toContain('VoltraDynamicLiveActivityOperations.endAll(') + expect(types).toContain('public static func definitionIds() -> [String]') + expect(types).toContain('definitions.map(\\.definitionId)') + expect(types).toContain('public static func startObserving(with observer: VoltraDynamicLiveActivityObserver) async') + expect(types).toContain('await observer.observe(VoltraDriverArrivedLiveActivityAttributes.self)') + expect(types).toContain('await observer.observe(VoltraOrderFinishedLiveActivityAttributes.self)') + expect(types).toContain('public static func reload(definitionIds: Set?) async') + expect(types).toContain( + 'VoltraDynamicLiveActivityOperations.reload(VoltraDriverArrivedLiveActivityAttributes.self)' + ) expect(configurations).toContain('VoltraDynamicLiveActivityRenderer.lockScreen(definitionId: "driver_arrived"') expect(configurations).toContain('VoltraDynamicLiveActivityRenderer.dynamicIsland(definitionId: "order_finished"') expect(configurations).toContain('.supplementalActivityFamilies([.small, .medium])') 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 07be4b71..a0ecfbf1 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 @@ -640,6 +640,31 @@ ${definitions .join(', ')}].flatMap { $0 }` } } + + public static func definitionIds() -> [String] { + definitions.map(\.definitionId) + } + + public static func startObserving(with observer: VoltraDynamicLiveActivityObserver) async { +${definitions + .map( + (liveActivity) => ` await observer.observe(${getDynamicLiveActivityAttributesType(liveActivity.id)}.self)` + ) + .join('\n')} + } + + public static func reload(definitionIds: Set?) async { +${definitions + .map((liveActivity) => { + const definitionId = escapeForSwiftStringLiteral(liveActivity.id) + return ` if definitionIds?.contains("${definitionId}") != false { + await VoltraDynamicLiveActivityOperations.reload(${getDynamicLiveActivityAttributesType( + liveActivity.id + )}.self) + }` + }) + .join('\n')} + } } ` diff --git a/packages/ios-client/ios/app/NativeVoltra.mm b/packages/ios-client/ios/app/NativeVoltra.mm index cc6cc377..839e9707 100644 --- a/packages/ios-client/ios/app/NativeVoltra.mm +++ b/packages/ios-client/ios/app/NativeVoltra.mm @@ -276,6 +276,11 @@ - (void)listVoltraActivityIds:(RCTPromiseResolveBlock)resolve reject:(RCTPromise resolve([self.module listVoltraActivityIds]); } +- (void)getDynamicLiveActivityDefinitionIds:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject +{ + resolve([self.module getDynamicLiveActivityDefinitionIds]); +} + - (NSNumber *)isLiveActivityActive:(NSString *)activityName { return @([self.module isLiveActivityActive:activityName]); @@ -302,6 +307,11 @@ - (void)reloadLiveActivities:(NSArray *)activityNames resolve:(RCTPromiseResolve }]; } +- (void)reloadDynamicLiveActivities:(NSArray *)definitionIds resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject +{ + [self.module reloadDynamicLiveActivities:definitionIds completion:^{ resolve(nil); }]; +} + - (void)clearPreloadedImages:(NSArray *)keys resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self.module clearPreloadedImages:keys completion:^{ resolve(nil); }]; diff --git a/packages/ios-client/ios/app/VoltraLiveActivityManager.swift b/packages/ios-client/ios/app/VoltraLiveActivityManager.swift index 9d8bad48..0cd05f47 100644 --- a/packages/ios-client/ios/app/VoltraLiveActivityManager.swift +++ b/packages/ios-client/ios/app/VoltraLiveActivityManager.swift @@ -30,6 +30,7 @@ public actor VoltraLiveActivityManager { /// Drives the main `Activity.activityUpdates` loop. When this task is cancelled, /// the loop exits and no new per-activity observers are created. private var activityUpdatesTask: Task? + private var isObserving = false /// Drives the `Activity.pushToStartTokenUpdates` loop (iOS 17.2+ only). private var pushToStartTask: Task? @@ -42,6 +43,11 @@ public actor VoltraLiveActivityManager { /// One lifecycle-state observer task per live activity, keyed by `activity.id`. private var stateTasks: [String: Task] = [:] + /// Generated Dynamic Live Activity types require separate ActivityKit streams. + /// This observer owns those streams while this manager continues to own the + /// unchanged legacy and app-wide push-to-start contracts. + private let dynamicObserver: VoltraDynamicLiveActivityObserver + /// The last push-to-start token we forwarded to the callback. /// ActivityKit re-delivers the current token whenever a live activity starts or /// ends (the push-to-start eligibility state has changed), even if the token @@ -59,6 +65,10 @@ public actor VoltraLiveActivityManager { self.onTokenUpdated = onTokenUpdated self.onPushToStartUpdated = onPushToStartUpdated self.onStateChanged = onStateChanged + dynamicObserver = VoltraDynamicLiveActivityObserver( + onTokenUpdated: onTokenUpdated, + onStateChanged: onStateChanged + ) } // MARK: - Public API @@ -69,9 +79,14 @@ public actor VoltraLiveActivityManager { /// To restart observation, call `stopObserving()` first. public func startObserving() { guard activityUpdatesTask == nil else { return } + isObserving = true startActivityUpdatesObservation() startPushToStartObservation() + Task { [weak self, dynamicObserver] in + guard await self?.currentlyObserving() == true else { return } + await VoltraDynamicLiveActivityCatalog.startObserving(with: dynamicObserver) + } } /// Stop all observation and cancel every outstanding task. @@ -79,6 +94,7 @@ public actor VoltraLiveActivityManager { /// Safe to call from any context. After this returns the actor holds no running /// tasks; calling `startObserving()` again creates a fresh set. public func stopObserving() { + isObserving = false activityUpdatesTask?.cancel() activityUpdatesTask = nil @@ -88,6 +104,13 @@ public actor VoltraLiveActivityManager { lastPushToStartToken = nil cancelAllPerActivityTasks() + Task { [dynamicObserver] in + await dynamicObserver.stopObserving() + } + } + + private func currentlyObserving() -> Bool { + isObserving } // MARK: - deinit diff --git a/packages/ios-client/ios/app/VoltraLiveActivityService.swift b/packages/ios-client/ios/app/VoltraLiveActivityService.swift index 8e4d7bd2..cb2f3f02 100644 --- a/packages/ios-client/ios/app/VoltraLiveActivityService.swift +++ b/packages/ios-client/ios/app/VoltraLiveActivityService.swift @@ -138,6 +138,12 @@ public class VoltraLiveActivityService { getAllActivityReferences().last?.id } + /// The installed capability list is generated during prebuild and does not + /// depend on Metro, the app group, or a server connection. + public func dynamicLiveActivityDefinitionIds() -> [String] { + VoltraDynamicLiveActivityCatalog.definitionIds() + } + // MARK: - Create Operations /// Create a new Live Activity @@ -324,6 +330,32 @@ public class VoltraLiveActivityService { } } + /// Refetch and re-evaluate only invalidated Dynamic Live Activity definitions, + /// then update their active instances with their current state to trigger a + /// WidgetKit render. Legacy activities are deliberately untouched. + public func reloadDynamicActivities(definitionIds: [String]?) async { + #if DEBUG + let requested = definitionIds.map(Set.init) + let ids = requested ?? Set(dynamicLiveActivityDefinitionIds()) + var refreshed = Set() + for definitionId in ids.sorted() { + guard VoltraDynamicLiveActivityCatalog.contains(definitionId) else { continue } + do { + let source = try VoltraDynamicLiveActivityBundleSource.load(definitionId: definitionId) + guard VoltraJSRenderer.evaluateLiveActivityBundle(source: source, definitionId: definitionId) else { + throw VoltraDynamicLiveActivityError.resourceUnavailable( + NSError(domain: "VoltraDynamicLiveActivity", code: -2, userInfo: [NSLocalizedDescriptionKey: "Dynamic Live Activity bundle could not be evaluated."]) + ) + } + refreshed.insert(definitionId) + } catch { + VoltraLogger.activity.error("Failed to refresh Dynamic Live Activity definition '\(definitionId)': \(error)") + } + } + await VoltraDynamicLiveActivityCatalog.reload(definitionIds: refreshed) + #endif + } + // MARK: - Monitoring private var activityManager: VoltraLiveActivityManager? diff --git a/packages/ios-client/ios/app/VoltraModule.swift b/packages/ios-client/ios/app/VoltraModule.swift index 0e8b218a..7faa7c8c 100644 --- a/packages/ios-client/ios/app/VoltraModule.swift +++ b/packages/ios-client/ios/app/VoltraModule.swift @@ -125,6 +125,10 @@ public enum VoltraErrors: Error { impl.listVoltraActivityIds() } + @objc public func getDynamicLiveActivityDefinitionIds() -> [String] { + impl.getDynamicLiveActivityDefinitionIds() + } + @objc public func isLiveActivityActive(_ activityName: String) -> Bool { impl.isLiveActivityActive(name: activityName) } @@ -168,6 +172,13 @@ public enum VoltraErrors: Error { } } + @objc public func reloadDynamicLiveActivities(_ definitionIds: NSArray?, completion: @escaping () -> Void) { + Task { + await impl.reloadDynamicLiveActivities(definitionIds: definitionIds?.compactMap { $0 as? String }) + completion() + } + } + @objc public func clearPreloadedImages(_ keys: NSArray?, completion: @escaping () -> Void) { Task { await impl.clearPreloadedImages(keys: keys?.compactMap { $0 as? String }) diff --git a/packages/ios-client/ios/app/VoltraModuleImpl.swift b/packages/ios-client/ios/app/VoltraModuleImpl.swift index cf2a6fbc..c64dc5dc 100644 --- a/packages/ios-client/ios/app/VoltraModuleImpl.swift +++ b/packages/ios-client/ios/app/VoltraModuleImpl.swift @@ -252,6 +252,11 @@ public class VoltraModuleImpl { return liveActivityService.getAllActivityReferences().map(\.id) } + func getDynamicLiveActivityDefinitionIds() -> [String] { + guard #available(iOS 16.4, *) else { return [] } + return liveActivityService.dynamicLiveActivityDefinitionIds() + } + func isLiveActivityActive(name: String) -> Bool { guard #available(iOS 16.4, *) else { return false } return liveActivityService.isActivityActive(name: name) @@ -287,6 +292,13 @@ public class VoltraModuleImpl { } } + func reloadDynamicLiveActivities(definitionIds: [String]?) async { + #if DEBUG + syncDevServerURL() + #endif + await liveActivityService.reloadDynamicActivities(definitionIds: definitionIds) + } + // MARK: - Image Preloading func preloadImages(images: [PreloadImageOptions]) async throws -> PreloadImagesResult { diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityObserver.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityObserver.swift new file mode 100644 index 00000000..1fee3ceb --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityObserver.swift @@ -0,0 +1,93 @@ +import ActivityKit +import Foundation + +/// Observes every concrete generated Dynamic Live Activity type. ActivityKit's +/// activity streams are type-specific, so the generated catalog invokes this +/// observer once for each bundled attributes type. +public actor VoltraDynamicLiveActivityObserver { + private let onTokenUpdated: (@Sendable (String, String) -> Void)? + private let onStateChanged: (@Sendable (String, String) -> Void)? + + private var definitionTasks: [String: Task] = [:] + private var tokenTasks: [String: Task] = [:] + private var stateTasks: [String: Task] = [:] + + public init( + onTokenUpdated: (@Sendable (String, String) -> Void)? = nil, + onStateChanged: (@Sendable (String, String) -> Void)? = nil + ) { + self.onTokenUpdated = onTokenUpdated + self.onStateChanged = onStateChanged + } + + public func observe(_: Attributes.Type) { + let definitionId = Attributes.definitionId + guard definitionTasks[definitionId] == nil else { return } + + definitionTasks[definitionId] = Task { [weak self] in + for activity in Activity.activities { + await self?.observe(activity, definitionId: definitionId) + } + for await activity in Activity.activityUpdates { + await self?.observe(activity, definitionId: definitionId) + } + await self?.removeDefinitionTask(for: definitionId) + } + } + + public func stopObserving() { + for task in definitionTasks.values { + task.cancel() + } + for task in tokenTasks.values { + task.cancel() + } + for task in stateTasks.values { + task.cancel() + } + definitionTasks.removeAll() + tokenTasks.removeAll() + stateTasks.removeAll() + } + + private func observe( + _ activity: Activity, + definitionId: String + ) { + let key = "\(definitionId):\(activity.id)" + let name = activity.attributes.name + + if let onTokenUpdated, tokenTasks[key] == nil { + tokenTasks[key] = Task { [weak self] in + for await token in activity.pushTokenUpdates { + onTokenUpdated(name, token.hexString) + } + await self?.removeTokenTask(for: key) + } + } + + if let onStateChanged, stateTasks[key] == nil { + stateTasks[key] = Task { [weak self] in + for await state in activity.activityStateUpdates { + onStateChanged(name, String(describing: state)) + } + await self?.removeStateTask(for: key) + } + } + } + + private func removeDefinitionTask(for definitionId: String) { + definitionTasks[definitionId]?.cancel() + definitionTasks.removeValue(forKey: definitionId) + } + + private func removeTokenTask(for key: String) { + tokenTasks[key]?.cancel() + tokenTasks.removeValue(forKey: key) + } + + private func removeStateTask(for key: String) { + stateTasks[key]?.cancel() + stateTasks.removeValue(forKey: key) + } +} diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift index 1b3d5d07..0b49b9d9 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift @@ -85,4 +85,16 @@ public enum VoltraDynamicLiveActivityOperations { VoltraDynamicLiveActivityReference(id: $0.id, name: $0.attributes.name, definitionId: Attributes.definitionId) } } + + public static func reload( + _: Attributes.Type + ) async { + for activity in Activity.activities { + await activity.update(ActivityContent( + state: activity.content.state, + staleDate: nil, + relevanceScore: 0.0 + )) + } + } } diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift index 78f2c89a..37e37da6 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift @@ -95,5 +95,8 @@ public enum VoltraDynamicLiveActivityError: Error { static func end(byName name: String, dismissalPolicy: ActivityUIDismissalPolicy) async -> Bool static func endAll(dismissalPolicy: ActivityUIDismissalPolicy) async static func activities() -> [VoltraDynamicLiveActivityReference] + static func definitionIds() -> [String] + static func startObserving(with observer: VoltraDynamicLiveActivityObserver) async + static func reload(definitionIds: Set?) async } #endif diff --git a/packages/ios-client/jest.dynamic-widget.config.js b/packages/ios-client/jest.dynamic-widget.config.js index ba745b74..5d3dfbe4 100644 --- a/packages/ios-client/jest.dynamic-widget.config.js +++ b/packages/ios-client/jest.dynamic-widget.config.js @@ -1,7 +1,10 @@ /** @type {import('jest').Config} */ module.exports = { testEnvironment: 'node', - testMatch: ['/tests/dynamic-widget/**/*.node.test.ts'], + testMatch: [ + '/tests/dynamic-widget/**/*.node.test.ts', + '/tests/dynamic-live-activity/**/*.node.test.ts', + ], modulePathIgnorePatterns: ['/build'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', diff --git a/packages/ios-client/src/index.ts b/packages/ios-client/src/index.ts index 3592fd59..42465f94 100644 --- a/packages/ios-client/src/index.ts +++ b/packages/ios-client/src/index.ts @@ -33,6 +33,7 @@ export { } from './preload.js' export { assertRunningOnApple } from './utils/assertRunningOnApple.js' export { enableWidgetHotReload } from './utils/enableWidgetHotReload.js' +export { enableDynamicLiveActivityHotReload } from './utils/enableDynamicLiveActivityHotReload.js' export { useUpdateOnHMR } from './utils/useUpdateOnHMR.js' export * from './utils/helpers.js' export type { VoltraElementJson, VoltraNodeJson } from './types.js' diff --git a/packages/ios-client/src/native/NativeVoltra.ts b/packages/ios-client/src/native/NativeVoltra.ts index 5c1bef5a..434dc092 100644 --- a/packages/ios-client/src/native/NativeVoltra.ts +++ b/packages/ios-client/src/native/NativeVoltra.ts @@ -97,10 +97,12 @@ export interface Spec extends TurboModule { endAllLiveActivities(): Promise getLatestVoltraActivityId(): Promise listVoltraActivityIds(): Promise + getDynamicLiveActivityDefinitionIds(): Promise isLiveActivityActive(activityName: string): boolean isHeadless(): boolean preloadImages(images: PreloadImageOptions[]): Promise reloadLiveActivities(activityNames?: string[] | null): Promise + reloadDynamicLiveActivities(definitionIds?: string[] | null): Promise clearPreloadedImages(keys?: string[] | null): Promise updateDynamicWidget(dynamicWidgetId: string, dynamicWidgetPropsJson: string): Promise updateWidget(widgetId: string, jsonString: string, options: UpdateWidgetOptions): Promise diff --git a/packages/ios-client/src/utils/enableDynamicLiveActivityHotReload.ts b/packages/ios-client/src/utils/enableDynamicLiveActivityHotReload.ts new file mode 100644 index 00000000..f4f54000 --- /dev/null +++ b/packages/ios-client/src/utils/enableDynamicLiveActivityHotReload.ts @@ -0,0 +1,24 @@ +import { getNativeVoltra } from '../VoltraModule.js' + +declare global { + var __voltraDynamicLiveActivityDefinitionUpdated: ((definitionId: string) => void) | undefined +} + +/** + * Reload only the supplied Dynamic Live Activity definitions after a Metro Fast + * Refresh patch. The generated definition module reports its own ID after Metro + * re-evaluates it, so only that definition's active instances are refreshed; + * legacy activities and Dynamic Widgets are unaffected. + */ +export function enableDynamicLiveActivityHotReload(): () => void { + if (!__DEV__) return () => {} + + const previous = global.__voltraDynamicLiveActivityDefinitionUpdated + global.__voltraDynamicLiveActivityDefinitionUpdated = (definitionId) => { + void getNativeVoltra().reloadDynamicLiveActivities([definitionId]) + } + + return () => { + global.__voltraDynamicLiveActivityDefinitionUpdated = previous + } +} diff --git a/packages/ios-client/tests/dynamic-live-activity/hotReload.node.test.ts b/packages/ios-client/tests/dynamic-live-activity/hotReload.node.test.ts new file mode 100644 index 00000000..352c6e76 --- /dev/null +++ b/packages/ios-client/tests/dynamic-live-activity/hotReload.node.test.ts @@ -0,0 +1,50 @@ +import { getNativeVoltra, type Spec } from '../../src/native/NativeVoltra.js' +import { enableDynamicLiveActivityHotReload } from '../../src/utils/enableDynamicLiveActivityHotReload.js' + +jest.mock('../../src/native/NativeVoltra.js', () => ({ + getNativeVoltra: jest.fn(), +})) + +const mockedGetNativeVoltra = jest.mocked(getNativeVoltra) + +describe('enableDynamicLiveActivityHotReload', () => { + const originalDev = global.__DEV__ + const originalDefinitionUpdated = global.__voltraDynamicLiveActivityDefinitionUpdated + + afterEach(() => { + global.__DEV__ = originalDev + global.__voltraDynamicLiveActivityDefinitionUpdated = originalDefinitionUpdated + jest.clearAllMocks() + }) + + it('reloads only the definition whose generated Metro module changed', () => { + global.__DEV__ = true + const reloadDynamicLiveActivities = jest.fn, [string[] | null]>() + reloadDynamicLiveActivities.mockResolvedValue(undefined) + const previous = jest.fn() + global.__voltraDynamicLiveActivityDefinitionUpdated = previous + mockedGetNativeVoltra.mockReturnValue({ reloadDynamicLiveActivities } as unknown as Spec) + + const dispose = enableDynamicLiveActivityHotReload() + global.__voltraDynamicLiveActivityDefinitionUpdated?.('order_finished') + + expect(reloadDynamicLiveActivities).toHaveBeenCalledWith(['order_finished']) + expect(previous).not.toHaveBeenCalled() + + dispose() + expect(global.__voltraDynamicLiveActivityDefinitionUpdated).toBe(previous) + }) + + it('does not install a callback in release builds', () => { + global.__DEV__ = false + const previous = jest.fn() + global.__voltraDynamicLiveActivityDefinitionUpdated = previous + + const dispose = enableDynamicLiveActivityHotReload() + global.__voltraDynamicLiveActivityDefinitionUpdated?.('order_finished') + + expect(mockedGetNativeVoltra).not.toHaveBeenCalled() + expect(previous).toHaveBeenCalledWith('order_finished') + dispose() + }) +}) diff --git a/packages/metro/src/liveActivityRegistry.ts b/packages/metro/src/liveActivityRegistry.ts index 3cd89fe8..0ba9fd5e 100644 --- a/packages/metro/src/liveActivityRegistry.ts +++ b/packages/metro/src/liveActivityRegistry.ts @@ -163,6 +163,9 @@ function createGeneratedEntry(projectRoot: string, generatedRoot: string, defini '', 'globalThis.__voltraDynamicLiveActivities = globalThis.__voltraDynamicLiveActivities || {}', `globalThis.__voltraDynamicLiveActivities[${JSON.stringify(definition.id)}] = { render }`, + 'if (typeof globalThis.__voltraDynamicLiveActivityDefinitionUpdated === "function") {', + ` globalThis.__voltraDynamicLiveActivityDefinitionUpdated(${JSON.stringify(definition.id)})`, + '}', '', 'export default render', '', diff --git a/packages/metro/src/widgetRegistry.node.test.ts b/packages/metro/src/widgetRegistry.node.test.ts index fb0fb02a..8998a8cc 100644 --- a/packages/metro/src/widgetRegistry.node.test.ts +++ b/packages/metro/src/widgetRegistry.node.test.ts @@ -253,6 +253,8 @@ describe('@use-voltra/metro manifest registry', () => { assert.match(generated, /renderLiveActivityToJson/) assert.match(generated, /new Date\(environment.date\)/) assert.match(generated, /__voltraDynamicLiveActivities/) + assert.match(generated, /__voltraDynamicLiveActivityDefinitionUpdated/) + assert.match(generated, /\("order"\)/) assert.match(generated, /LiveActivity\(props, environment\)/) } finally { widgets.close() From 602ea2e5b89944640882441e2190d471a1461472 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 09:21:12 +0200 Subject: [PATCH 09/24] feat(ios): report dynamic live activity render failures --- packages/ios-client/ios/Package.swift | 1 + ...cLiveActivityRenderFailureQueueTests.swift | 78 ++++++++++ packages/ios-client/ios/app/NativeVoltra.mm | 3 + .../ios-client/ios/app/VoltraModule.swift | 4 + .../ios-client/ios/app/VoltraModuleImpl.swift | 4 + .../ios/shared/VoltraEventBus.swift | 46 +++++- ...ynamicLiveActivityRenderFailureQueue.swift | 97 ++++++++++++ ...micLiveActivityRenderFailureReporter.swift | 138 ++++++++++++++++++ .../VoltraDynamicLiveActivityRenderer.swift | 21 ++- packages/ios-client/src/events.ts | 12 ++ .../ios-client/src/native/NativeVoltra.ts | 10 ++ .../renderFailureEvents.node.test.ts | 23 +++ 12 files changed, 423 insertions(+), 14 deletions(-) create mode 100644 packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityRenderFailureQueueTests.swift create mode 100644 packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift create mode 100644 packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift create mode 100644 packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts diff --git a/packages/ios-client/ios/Package.swift b/packages/ios-client/ios/Package.swift index a5b575df..b87af345 100644 --- a/packages/ios-client/ios/Package.swift +++ b/packages/ios-client/ios/Package.swift @@ -44,6 +44,7 @@ let package = Package( "DynamicWidgetUpdater.swift", "dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift", "dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift", + "dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift", "JSONValue.swift", "VoltraConfig.swift", "VoltraConstants.swift", diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityRenderFailureQueueTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityRenderFailureQueueTests.swift new file mode 100644 index 00000000..1ddde57a --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityRenderFailureQueueTests.swift @@ -0,0 +1,78 @@ +@testable import VoltraSharedCore +import XCTest + +final class DynamicLiveActivityRenderFailureQueueTests: XCTestCase { + func testCapsQueueAtOneHundredAndDropsOldestFailuresInOrder() { + let storage = InMemoryRenderFailureStorage() + let queue = VoltraDynamicLiveActivityRenderFailureQueue(storage: storage) + + for index in 0 ... 100 { + XCTAssertTrue(queue.record(failure(index))) + } + + let failures = queue.drain() + XCTAssertEqual(failures.count, 100) + XCTAssertEqual(failures.first?.activityName, "activity-1") + XCTAssertEqual(failures.last?.activityName, "activity-100") + } + + func testDoesNotDeduplicateEquivalentFailures() { + let queue = VoltraDynamicLiveActivityRenderFailureQueue(storage: InMemoryRenderFailureStorage()) + let repeated = failure(1) + + XCTAssertTrue(queue.record(repeated)) + XCTAssertTrue(queue.record(repeated)) + + XCTAssertEqual(queue.drain(), [repeated, repeated]) + } + + func testPersistsOnlyTheApprovedSanitizedFields() { + let failure = VoltraDynamicLiveActivityRenderFailure( + activityName: "order-123", + definitionId: "order_finished", + message: " invalid props\ncontained\tprivate data ", + timestamp: Date(timeIntervalSince1970: 123) + ) + + XCTAssertEqual( + Set(failure.dictionary.keys), + ["type", "source", "timestamp", "activityName", "definitionId", "message"] + ) + XCTAssertEqual(failure.type, "dynamicLiveActivityRenderFailed") + XCTAssertEqual(failure.source, "order-123") + XCTAssertEqual(failure.message, "invalid props contained private data") + } + + func testDedicatedStorageNeverMutatesInteractionQueueData() { + let storage = InMemoryRenderFailureStorage() + storage.interactionEvents = ["interaction event"] + let queue = VoltraDynamicLiveActivityRenderFailureQueue(storage: storage) + + XCTAssertTrue(queue.record(failure(1))) + _ = queue.drain() + + XCTAssertEqual(storage.interactionEvents, ["interaction event"]) + } + + private func failure(_ index: Int) -> VoltraDynamicLiveActivityRenderFailure { + VoltraDynamicLiveActivityRenderFailure( + activityName: "activity-\(index)", + definitionId: "definition", + message: "failure \(index)", + timestamp: Date(timeIntervalSince1970: Double(index)) + ) + } +} + +private final class InMemoryRenderFailureStorage: VoltraDynamicLiveActivityRenderFailureStorage { + var failures: [VoltraDynamicLiveActivityRenderFailure] = [] + var interactionEvents: [String] = [] + + func load() throws -> [VoltraDynamicLiveActivityRenderFailure] { + failures + } + + func save(_ failures: [VoltraDynamicLiveActivityRenderFailure]) throws { + self.failures = failures + } +} diff --git a/packages/ios-client/ios/app/NativeVoltra.mm b/packages/ios-client/ios/app/NativeVoltra.mm index 839e9707..5167f603 100644 --- a/packages/ios-client/ios/app/NativeVoltra.mm +++ b/packages/ios-client/ios/app/NativeVoltra.mm @@ -65,6 +65,8 @@ - (void)setEventEmitterCallback:(EventEmitterCallbackWrapper *_Nonnull)eventEmit if ([eventName isEqualToString:@"interaction"]) { [strongSelf emitOnInteraction:eventData]; + } else if ([eventName isEqualToString:@"dynamicLiveActivityRenderFailed"]) { + [strongSelf emitOnDynamicLiveActivityRenderFailed:eventData]; } else if ([eventName isEqualToString:@"stateChange"]) { [strongSelf emitOnStateChanged:eventData]; } else if ([eventName isEqualToString:@"activityTokenReceived"]) { @@ -86,6 +88,7 @@ - (VoltraModule *)module - (void)applicationWillEnterForeground { [self.module clearHeadless]; + [self.module drainDynamicLiveActivityRenderFailures]; [self updateRootAppPropertiesHeadless:NO]; } diff --git a/packages/ios-client/ios/app/VoltraModule.swift b/packages/ios-client/ios/app/VoltraModule.swift index 7faa7c8c..d1bb02ef 100644 --- a/packages/ios-client/ios/app/VoltraModule.swift +++ b/packages/ios-client/ios/app/VoltraModule.swift @@ -141,6 +141,10 @@ public enum VoltraErrors: Error { impl.clearHeadless() } + @objc public func drainDynamicLiveActivityRenderFailures() { + impl.drainDynamicLiveActivityRenderFailures() + } + // MARK: - Images @objc public func preloadImages( diff --git a/packages/ios-client/ios/app/VoltraModuleImpl.swift b/packages/ios-client/ios/app/VoltraModuleImpl.swift index c64dc5dc..ff519d43 100644 --- a/packages/ios-client/ios/app/VoltraModuleImpl.swift +++ b/packages/ios-client/ios/app/VoltraModuleImpl.swift @@ -89,6 +89,10 @@ public class VoltraModuleImpl { VoltraHeadlessState.shared.clear() } + func drainDynamicLiveActivityRenderFailures() { + VoltraEventBus.shared.drainDynamicLiveActivityRenderFailures() + } + var pushNotificationsEnabled: Bool { // Support both keys for compatibility with older plugin and new Voltra naming let main = Bundle.main diff --git a/packages/ios-client/ios/shared/VoltraEventBus.swift b/packages/ios-client/ios/shared/VoltraEventBus.swift index c1934e81..aaf7256b 100644 --- a/packages/ios-client/ios/shared/VoltraEventBus.swift +++ b/packages/ios-client/ios/shared/VoltraEventBus.swift @@ -8,6 +8,8 @@ public class VoltraEventBus { public static let shared = VoltraEventBus() private var observer: NSObjectProtocol? + private var renderFailureObserver: UUID? + private var handler: ((String, [String: Any]) -> Void)? private let lock = NSLock() private init() {} @@ -39,20 +41,18 @@ public class VoltraEventBus { /// - Parameter handler: A closure that receives the event name and event data dictionary public func subscribe(handler: @escaping (String, [String: Any]) -> Void) { lock.lock() - defer { lock.unlock() } - if let observer { NotificationCenter.default.removeObserver(observer) self.observer = nil } + if let renderFailureObserver { + VoltraDynamicLiveActivityRenderFailureReporter.removeChangeObserver(renderFailureObserver) + self.renderFailureObserver = nil + } + self.handler = handler // 1. Replay persisted events from UserDefaults (interactions from widget) let persistedEvents = VoltraPersistentEventQueue.popAll() - for event in persistedEvents { - handler(event.name, event.data) - } - VoltraLogger.event.info("Replayed \(persistedEvents.count) persisted events") - // 2. Listen for all events via NotificationCenter (hot delivery) observer = NotificationCenter.default.addObserver( forName: .voltraEvent, @@ -66,6 +66,33 @@ public class VoltraEventBus { } handler(eventName, userInfo) } + renderFailureObserver = VoltraDynamicLiveActivityRenderFailureReporter.observeChanges { [weak self] in + self?.drainDynamicLiveActivityRenderFailures() + } + lock.unlock() + + for event in persistedEvents { + handler(event.name, event.data) + } + VoltraLogger.event.info("Replayed \(persistedEvents.count) persisted events") + drainDynamicLiveActivityRenderFailures() + } + + /// Flush only the dedicated Dynamic Live Activity diagnostic queue. This + /// cannot read, displace, or clear persistent interaction events. + public func drainDynamicLiveActivityRenderFailures() { + lock.lock() + let handler = handler + lock.unlock() + guard let handler else { return } + + let failures = VoltraDynamicLiveActivityRenderFailureReporter.drain() + for failure in failures { + handler(failure.type, failure.dictionary) + } + if !failures.isEmpty { + VoltraLogger.event.info("Replayed \(failures.count) Dynamic Live Activity render failures") + } } /// Unsubscribe from Voltra events @@ -77,6 +104,11 @@ public class VoltraEventBus { NotificationCenter.default.removeObserver(observer) self.observer = nil } + if let renderFailureObserver { + VoltraDynamicLiveActivityRenderFailureReporter.removeChangeObserver(renderFailureObserver) + self.renderFailureObserver = nil + } + handler = nil } deinit { diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift new file mode 100644 index 00000000..270ff6cd --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift @@ -0,0 +1,97 @@ +import Foundation + +/// The intentionally minimal diagnostic record sent from the widget extension +/// to the app after a Dynamic Live Activity definition cannot be rendered. +/// Do not add props, tokens, stages, or arbitrary error details here. +public struct VoltraDynamicLiveActivityRenderFailure: Codable, Equatable { + public static let eventType = "dynamicLiveActivityRenderFailed" + + public let type: String + public let source: String + public let timestamp: TimeInterval + public let activityName: String + public let definitionId: String + public let message: String + + public init(activityName: String, definitionId: String, message: String, timestamp: Date = Date()) { + type = Self.eventType + source = activityName + self.timestamp = timestamp.timeIntervalSince1970 + self.activityName = activityName + self.definitionId = definitionId + self.message = Self.sanitize(message) + } + + public var dictionary: [String: Any] { + [ + "type": type, + "source": source, + "timestamp": timestamp, + "activityName": activityName, + "definitionId": definitionId, + "message": message, + ] + } + + /// Error descriptions can contain line breaks and unbounded implementation + /// details. Keep the diagnostic useful while making the persisted contract + /// predictable and safe to expose to JavaScript. + public static func sanitize(_ message: String) -> String { + let collapsed = message + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + let fallback = "Dynamic Live Activity rendering failed." + return String((collapsed.isEmpty ? fallback : collapsed).prefix(500)) + } +} + +public protocol VoltraDynamicLiveActivityRenderFailureStorage { + func load() throws -> [VoltraDynamicLiveActivityRenderFailure] + func save(_ failures: [VoltraDynamicLiveActivityRenderFailure]) throws +} + +/// A dedicated, bounded queue. Its lock makes draining and appending atomic in +/// a process: a failure recorded while a drain is in progress remains queued +/// for the next drain rather than being cleared accidentally. +public final class VoltraDynamicLiveActivityRenderFailureQueue { + public static let capacity = 100 + + private let storage: VoltraDynamicLiveActivityRenderFailureStorage + private let lock = NSLock() + + public init(storage: VoltraDynamicLiveActivityRenderFailureStorage) { + self.storage = storage + } + + @discardableResult + public func record(_ failure: VoltraDynamicLiveActivityRenderFailure) -> Bool { + lock.lock() + defer { lock.unlock() } + + do { + var failures = try storage.load() + failures.append(failure) + if failures.count > Self.capacity { + failures.removeFirst(failures.count - Self.capacity) + } + try storage.save(failures) + return true + } catch { + return false + } + } + + public func drain() -> [VoltraDynamicLiveActivityRenderFailure] { + lock.lock() + defer { lock.unlock() } + + do { + let failures = try storage.load() + try storage.save([]) + return failures + } catch { + return [] + } + } +} diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift new file mode 100644 index 00000000..f6bbe3a7 --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift @@ -0,0 +1,138 @@ +import CoreFoundation +import Foundation + +private final class VoltraDynamicLiveActivityRenderFailureUserDefaultsStorage: VoltraDynamicLiveActivityRenderFailureStorage { + private static let key = "Voltra_DynamicLiveActivity_RenderFailures_v1" + + func load() throws -> [VoltraDynamicLiveActivityRenderFailure] { + guard let defaults = defaults() else { throw StorageError.unavailable } + guard let data = defaults.data(forKey: Self.key) else { return [] } + return try JSONDecoder().decode([VoltraDynamicLiveActivityRenderFailure].self, from: data) + } + + func save(_ failures: [VoltraDynamicLiveActivityRenderFailure]) throws { + guard let defaults = defaults() else { throw StorageError.unavailable } + try defaults.set(JSONEncoder().encode(failures), forKey: Self.key) + guard defaults.synchronize() else { throw StorageError.writeFailed } + } + + private func defaults() -> UserDefaults? { + guard let group = VoltraConfig.groupIdentifier() else { return nil } + return UserDefaults(suiteName: group) + } + + private enum StorageError: Error { + case unavailable + case writeFailed + } +} + +/// Cross-process notification relay for the App Group backed failure queue. +/// Darwin notifications carry no payload, so consumers always drain storage. +private final class VoltraDynamicLiveActivityRenderFailureNotifier { + static let shared = VoltraDynamicLiveActivityRenderFailureNotifier() + + private let lock = NSLock() + private var handlers: [UUID: () -> Void] = [:] + private let name = "com.voltra.dynamicLiveActivityRenderFailures" as CFString + + private init() { + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + Unmanaged.passUnretained(self).toOpaque(), + Self.didReceive, + name, + nil, + .deliverImmediately + ) + } + + deinit { + CFNotificationCenterRemoveObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + Unmanaged.passUnretained(self).toOpaque(), + CFNotificationName(name), + nil + ) + } + + func add(_ handler: @escaping () -> Void) -> UUID { + let token = UUID() + lock.lock() + handlers[token] = handler + lock.unlock() + return token + } + + func remove(_ token: UUID) { + lock.lock() + handlers.removeValue(forKey: token) + lock.unlock() + } + + func post() { + notifyHandlers() + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(name), + nil, + nil, + true + ) + } + + private static let didReceive: CFNotificationCallback = { _, observer, _, _, _ in + guard let observer else { return } + Unmanaged + .fromOpaque(observer) + .takeUnretainedValue() + .notifyHandlers() + } + + private func notifyHandlers() { + lock.lock() + let currentHandlers = Array(handlers.values) + lock.unlock() + currentHandlers.forEach { $0() } + } +} + +/// Owns the production App Group queue and OS logging path. The extension only +/// records failures; the app's event bus drains them when JavaScript can listen. +public enum VoltraDynamicLiveActivityRenderFailureReporter { + private static let queue = VoltraDynamicLiveActivityRenderFailureQueue( + storage: VoltraDynamicLiveActivityRenderFailureUserDefaultsStorage() + ) + + @discardableResult + public static func record(activityName: String, definitionId: String, message: String) -> Bool { + let failure = VoltraDynamicLiveActivityRenderFailure( + activityName: activityName, + definitionId: definitionId, + message: message + ) + let persisted = queue.record(failure) + VoltraLogger.activity.error( + "[DynamicLiveActivity] activity=\(failure.activityName, privacy: .public) definitionId=\(failure.definitionId, privacy: .public) \(failure.message, privacy: .public)" + ) + if persisted { + VoltraDynamicLiveActivityRenderFailureNotifier.shared.post() + } else { + VoltraLogger.event.error("Failed to persist Dynamic Live Activity render failure") + } + return persisted + } + + public static func drain() -> [VoltraDynamicLiveActivityRenderFailure] { + queue.drain() + } + + /// The returned token must be removed when the JS bridge listener goes away. + public static func observeChanges(_ handler: @escaping () -> Void) -> UUID { + VoltraDynamicLiveActivityRenderFailureNotifier.shared.add(handler) + } + + public static func removeChangeObserver(_ token: UUID) { + VoltraDynamicLiveActivityRenderFailureNotifier.shared.remove(token) + } +} diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift index 900b6716..d7bf627a 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift @@ -57,8 +57,12 @@ public enum VoltraDynamicLiveActivityRenderer { locale: Locale = .current, widgetRenderingMode: WidgetRenderingMode = .fullColor ) -> VoltraDynamicLiveActivityResolvedContent { + guard VoltraDynamicLiveActivityCatalog.contains(definitionId) else { + logFailure(definitionId: definitionId, activityName: context.attributes.name, message: "Definition is missing from the installed catalog") + return .empty + } guard let propsJSON = DynamicLiveActivityPropsCodec.encode(context.state.props) else { - logFailure(definitionId: definitionId, message: "Could not encode content-state props") + logFailure(definitionId: definitionId, activityName: context.attributes.name, message: "Could not encode content-state props") return .empty } let environmentJSON = VoltraDynamicLiveActivityEnvironmentBuilder.build( @@ -73,7 +77,7 @@ public enum VoltraDynamicLiveActivityRenderer { do { let source = try VoltraDynamicLiveActivityBundleSource.load(definitionId: definitionId) guard VoltraJSRenderer.ensureLiveActivityEvaluated(definitionId: definitionId, source: source) else { - logFailure(definitionId: definitionId, message: "Could not evaluate definition bundle") + logFailure(definitionId: definitionId, activityName: context.attributes.name, message: "Could not evaluate definition bundle") return .empty } guard let renderedJSON = VoltraJSRenderer.renderLiveActivity( @@ -81,20 +85,23 @@ public enum VoltraDynamicLiveActivityRenderer { propsJSON: propsJSON, envJSON: environmentJSON ) else { - logFailure(definitionId: definitionId, message: "Definition render failed") + logFailure(definitionId: definitionId, activityName: context.attributes.name, message: "Definition render failed") return .empty } let payload = try VoltraLiveActivityPayload(jsonString: renderedJSON) return VoltraDynamicLiveActivityResolvedContent(payload: payload) } catch { - logFailure(definitionId: definitionId, message: error.localizedDescription) + logFailure(definitionId: definitionId, activityName: context.attributes.name, message: error.localizedDescription) return .empty } } - fileprivate static func logFailure(definitionId: String, message: String) { - // Task 07 will additionally persist this structured failure in the App Group queue. - VoltraLogger.activity.error("[DynamicLiveActivity] definitionId=\(definitionId) \(message)") + fileprivate static func logFailure(definitionId: String, activityName: String, message: String) { + VoltraDynamicLiveActivityRenderFailureReporter.record( + activityName: activityName, + definitionId: definitionId, + message: message + ) } } diff --git a/packages/ios-client/src/events.ts b/packages/ios-client/src/events.ts index 171251dc..f77d2a4b 100644 --- a/packages/ios-client/src/events.ts +++ b/packages/ios-client/src/events.ts @@ -30,6 +30,13 @@ export type VoltraInteractionEvent = BasicVoltraEvent & { payload: string } +export type VoltraDynamicLiveActivityRenderFailedEvent = BasicVoltraEvent & { + type: 'dynamicLiveActivityRenderFailed' + activityName: string + definitionId: string + message: string +} + const noopSubscription: EventSubscription = { remove: () => {}, } @@ -39,6 +46,7 @@ export type VoltraEventMap = { activityPushToStartTokenReceived: VoltraActivityPushToStartTokenReceivedEvent stateChange: VoltraActivityUpdateEvent interaction: VoltraInteractionEvent + dynamicLiveActivityRenderFailed: VoltraDynamicLiveActivityRenderFailedEvent } export function addVoltraListener( @@ -63,6 +71,10 @@ export function addVoltraListener( return voltraModule.onStateChanged(listener as (arg: VoltraActivityUpdateEvent) => void) case 'interaction': return voltraModule.onInteraction(listener as (arg: VoltraInteractionEvent) => void) + case 'dynamicLiveActivityRenderFailed': + return voltraModule.onDynamicLiveActivityRenderFailed( + listener as (arg: VoltraDynamicLiveActivityRenderFailedEvent) => void + ) default: console.warn(`[Voltra] Event '${event}' is not supported. Returning no-op subscription.`) return noopSubscription diff --git a/packages/ios-client/src/native/NativeVoltra.ts b/packages/ios-client/src/native/NativeVoltra.ts index 434dc092..e827956e 100644 --- a/packages/ios-client/src/native/NativeVoltra.ts +++ b/packages/ios-client/src/native/NativeVoltra.ts @@ -32,6 +32,15 @@ type VoltraInteractionEvent = Readonly<{ payload: string }> +type VoltraDynamicLiveActivityRenderFailedEvent = Readonly<{ + source: string + timestamp: number + type: 'dynamicLiveActivityRenderFailed' + activityName: string + definitionId: string + message: string +}> + type StartVoltraOptions = Readonly<{ target?: string deepLinkUrl?: string @@ -86,6 +95,7 @@ type WidgetServerCredentials = Readonly<{ export interface Spec extends TurboModule { readonly onInteraction: CodegenTypes.EventEmitter + readonly onDynamicLiveActivityRenderFailed: CodegenTypes.EventEmitter readonly onStateChanged: CodegenTypes.EventEmitter readonly onActivityTokenReceived: CodegenTypes.EventEmitter readonly onActivityPushToStartTokenReceived: CodegenTypes.EventEmitter diff --git a/packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts b/packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts new file mode 100644 index 00000000..f262c77c --- /dev/null +++ b/packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts @@ -0,0 +1,23 @@ +import { addVoltraListener, type VoltraDynamicLiveActivityRenderFailedEvent } from '../../src/events.js' +import { getNativeVoltra, type Spec } from '../../src/VoltraModule.js' + +jest.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +jest.mock('../../src/VoltraModule.js', () => ({ getNativeVoltra: jest.fn() })) + +const mockedGetNativeVoltra = jest.mocked(getNativeVoltra) + +describe('Dynamic Live Activity render failure events', () => { + afterEach(() => jest.clearAllMocks()) + + it('subscribes through the dedicated native emitter with the exported event shape', () => { + const subscription = { remove: jest.fn() } + const onDynamicLiveActivityRenderFailed = jest.fn(() => subscription) + mockedGetNativeVoltra.mockReturnValue({ onDynamicLiveActivityRenderFailed } as unknown as Spec) + const listener = jest.fn<(event: VoltraDynamicLiveActivityRenderFailedEvent) => void>() + + const returned = addVoltraListener('dynamicLiveActivityRenderFailed', listener) + + expect(onDynamicLiveActivityRenderFailed).toHaveBeenCalledWith(listener) + expect(returned).toBe(subscription) + }) +}) From 1e5da429e31e57928edb0101a239443be8143bbe Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 09:26:17 +0200 Subject: [PATCH 10/24] feat(ios-client): add dynamic live activity APIs --- .../ios-client/jest.dynamic-widget.config.js | 1 + packages/ios-client/src/index.ts | 12 + packages/ios-client/src/live-activity/api.ts | 4 +- .../src/live-activity/dynamic-api.ts | 240 ++++++++++++++++++ .../dynamic-live-activity/api.node.test.ts | 172 +++++++++++++ packages/ios-client/tests/setup.node.js | 2 + 6 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 packages/ios-client/src/live-activity/dynamic-api.ts create mode 100644 packages/ios-client/tests/dynamic-live-activity/api.node.test.ts create mode 100644 packages/ios-client/tests/setup.node.js diff --git a/packages/ios-client/jest.dynamic-widget.config.js b/packages/ios-client/jest.dynamic-widget.config.js index 5d3dfbe4..7dd2f0e0 100644 --- a/packages/ios-client/jest.dynamic-widget.config.js +++ b/packages/ios-client/jest.dynamic-widget.config.js @@ -1,6 +1,7 @@ /** @type {import('jest').Config} */ module.exports = { testEnvironment: 'node', + setupFiles: ['/tests/setup.node.js'], testMatch: [ '/tests/dynamic-widget/**/*.node.test.ts', '/tests/dynamic-live-activity/**/*.node.test.ts', diff --git a/packages/ios-client/src/index.ts b/packages/ios-client/src/index.ts index 42465f94..460324b4 100644 --- a/packages/ios-client/src/index.ts +++ b/packages/ios-client/src/index.ts @@ -23,6 +23,18 @@ export { type UseLiveActivityOptions, type UseLiveActivityResult, } from './live-activity/api.js' +export { + getDynamicLiveActivityDefinitionIds, + startDynamicLiveActivity, + type StartDynamicLiveActivityOptions, + updateDynamicLiveActivity, + type UpdateDynamicLiveActivityOptions, + useDynamicLiveActivity, + type UseDynamicLiveActivityOptions, + type UseDynamicLiveActivityResult, + type DynamicLiveActivityProps, + type DynamicLiveActivityPropsValue, +} from './live-activity/dynamic-api.js' export type { DismissalPolicy, LiveActivityVariants } from '@use-voltra/ios' export { clearPreloadedImages, diff --git a/packages/ios-client/src/live-activity/api.ts b/packages/ios-client/src/live-activity/api.ts index 5d43833d..1a5c2285 100644 --- a/packages/ios-client/src/live-activity/api.ts +++ b/packages/ios-client/src/live-activity/api.ts @@ -40,7 +40,7 @@ export type UseLiveActivityResult = { isActive: boolean } -const normalizeSharedLiveActivityOptions = ( +export const normalizeSharedLiveActivityOptions = ( options?: SharedLiveActivityOptions ): SharedLiveActivityOptions | undefined => { if (!options) return undefined @@ -66,7 +66,7 @@ const normalizeSharedLiveActivityOptions = ( return Object.keys(normalizedOptions).length > 0 ? normalizedOptions : undefined } -const normalizeEndLiveActivityOptions = ( +export const normalizeEndLiveActivityOptions = ( options?: EndLiveActivityOptions ): { dismissalPolicy?: { type: 'immediate' | 'after'; date?: number } } | undefined => { if (!options?.dismissalPolicy) return undefined diff --git a/packages/ios-client/src/live-activity/dynamic-api.ts b/packages/ios-client/src/live-activity/dynamic-api.ts new file mode 100644 index 00000000..eb9ab6b6 --- /dev/null +++ b/packages/ios-client/src/live-activity/dynamic-api.ts @@ -0,0 +1,240 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { addVoltraListener } from '../events.js' +import { assertRunningOnApple, useUpdateOnHMR } from '../utils/index.js' +import { getNativeVoltra } from '../VoltraModule.js' +import { + isLiveActivityActive, + normalizeSharedLiveActivityOptions, + stopLiveActivity, + type EndLiveActivityOptions, + type SharedLiveActivityOptions, +} from './api.js' + +/** @experimental A JSON-compatible value accepted as Dynamic Live Activity props. */ +export type DynamicLiveActivityPropsValue = + | string + | number + | boolean + | null + | ReadonlyArray + | Readonly<{ [key: string]: DynamicLiveActivityPropsValue }> + +/** @experimental A complete JSON-compatible props record for a Dynamic Live Activity update. */ +export type DynamicLiveActivityProps = Readonly<{ [key: string]: DynamicLiveActivityPropsValue }> + +/** @experimental Options used to start a Dynamic Live Activity. */ +export type StartDynamicLiveActivityOptions = { + activityName?: string + deepLinkUrl?: string + channelId?: string +} & SharedLiveActivityOptions + +/** @experimental Options used to update a Dynamic Live Activity. */ +export type UpdateDynamicLiveActivityOptions = SharedLiveActivityOptions + +/** @experimental Options for `useDynamicLiveActivity`. */ +export type UseDynamicLiveActivityOptions = StartDynamicLiveActivityOptions & { + autoStart?: boolean + autoUpdate?: boolean +} + +/** @experimental Lifecycle controls returned by `useDynamicLiveActivity`. */ +export type UseDynamicLiveActivityResult = { + start: (options?: StartDynamicLiveActivityOptions) => Promise + update: (options?: UpdateDynamicLiveActivityOptions) => Promise + end: (options?: EndLiveActivityOptions) => Promise + isActive: boolean +} + +function serializeDynamicLiveActivityProps(props: DynamicLiveActivityProps): string { + const ancestors = new Set() + + const validate = (value: unknown, path: string): void => { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return + + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new TypeError(`Dynamic Live Activity props must be JSON-compatible; ${path} is not a finite number.`) + } + return + } + + if (Array.isArray(value)) { + if (ancestors.has(value)) { + throw new TypeError( + `Dynamic Live Activity props must be JSON-compatible; ${path} contains a circular reference.` + ) + } + ancestors.add(value) + value.forEach((item, index) => validate(item, `${path}[${index}]`)) + ancestors.delete(value) + return + } + + if (typeof value === 'object') { + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`Dynamic Live Activity props must be JSON-compatible; ${path} must be a plain object.`) + } + if (ancestors.has(value)) { + throw new TypeError( + `Dynamic Live Activity props must be JSON-compatible; ${path} contains a circular reference.` + ) + } + ancestors.add(value) + for (const [key, item] of Object.entries(value as Record)) { + validate(item, `${path}.${key}`) + } + ancestors.delete(value) + return + } + + throw new TypeError( + `Dynamic Live Activity props must be JSON-compatible; ${path} has unsupported type ${typeof value}.` + ) + } + + if (props === null || Array.isArray(props) || typeof props !== 'object') { + throw new TypeError('Dynamic Live Activity props must be a JSON-compatible record.') + } + + validate(props, 'props') + return JSON.stringify(props) +} + +/** + * Starts a bundled Dynamic Live Activity with a complete opaque props record. + * @experimental + */ +export async function startDynamicLiveActivity( + definitionId: string, + props: DynamicLiveActivityProps, + options?: StartDynamicLiveActivityOptions +): Promise { + if (!assertRunningOnApple()) return '' + + const propsJson = serializeDynamicLiveActivityProps(props) + const normalizedSharedOptions = normalizeSharedLiveActivityOptions(options) + return getNativeVoltra().startDynamicLiveActivity(definitionId, propsJson, { + target: 'liveActivity', + deepLinkUrl: options?.deepLinkUrl, + activityName: options?.activityName, + channelId: options?.channelId, + ...normalizedSharedOptions, + }) +} + +/** + * Replaces a Dynamic Live Activity's complete opaque props record. + * @experimental + */ +export async function updateDynamicLiveActivity( + activityName: string, + props: DynamicLiveActivityProps, + options?: UpdateDynamicLiveActivityOptions +): Promise { + if (!assertRunningOnApple()) return + + const propsJson = serializeDynamicLiveActivityProps(props) + return getNativeVoltra().updateDynamicLiveActivity( + activityName, + propsJson, + normalizeSharedLiveActivityOptions(options) ?? {} + ) +} + +/** + * Returns the Dynamic Live Activity definition IDs bundled in the installed app. + * @experimental + */ +export async function getDynamicLiveActivityDefinitionIds(): Promise { + if (!assertRunningOnApple()) return [] + return getNativeVoltra().getDynamicLiveActivityDefinitionIds() +} + +/** + * Manages the lifecycle of one Dynamic Live Activity definition. + * + * Complete props are sent on every update. In development, call + * `enableDynamicLiveActivityHotReload()` once in the app host to reload only + * the definition changed by Fast Refresh. + * @experimental + */ +export function useDynamicLiveActivity( + definitionId: string, + props: Props, + options?: UseDynamicLiveActivityOptions +): UseDynamicLiveActivityResult { + const [activityName, setActivityName] = useState(() => { + if (options?.activityName) { + return isLiveActivityActive(options.activityName) ? options.activityName : null + } + return null + }) + const propsRef = useRef(props) + const optionsRef = useRef(options) + const lastUpdateOptionsRef = useRef(undefined) + + useEffect(() => { + propsRef.current = props + }, [props]) + + useEffect(() => { + optionsRef.current = options + }, [options]) + + useUpdateOnHMR() + + const start = useCallback( + async (startOptions?: StartDynamicLiveActivityOptions) => { + const id = await startDynamicLiveActivity(definitionId, propsRef.current, { + ...optionsRef.current, + ...startOptions, + }) + setActivityName(id) + }, + [definitionId] + ) + + const update = useCallback( + async (updateOptions?: UpdateDynamicLiveActivityOptions) => { + if (!activityName) return + + const mergedOptions = { ...optionsRef.current, ...updateOptions } + lastUpdateOptionsRef.current = mergedOptions + await updateDynamicLiveActivity(activityName, propsRef.current, mergedOptions) + }, + [activityName] + ) + + const end = useCallback( + async (endOptions?: EndLiveActivityOptions) => { + if (!activityName) return + + await stopLiveActivity(activityName, endOptions) + setActivityName(null) + }, + [activityName] + ) + + useEffect(() => { + if (options?.autoStart) void start() + }, [options?.autoStart, start]) + + useEffect(() => { + if (options?.autoUpdate) void update(lastUpdateOptionsRef.current) + }, [options?.autoUpdate, props, update]) + + useEffect(() => { + if (!activityName) return + + const subscription = addVoltraListener('stateChange', (event) => { + if (event.activityName !== activityName) return + if (event.activityState === 'dismissed' || event.activityState === 'ended') setActivityName(null) + }) + return () => subscription.remove() + }, [activityName]) + + return { start, update, end, isActive: activityName !== null } +} diff --git a/packages/ios-client/tests/dynamic-live-activity/api.node.test.ts b/packages/ios-client/tests/dynamic-live-activity/api.node.test.ts new file mode 100644 index 00000000..67c0a57d --- /dev/null +++ b/packages/ios-client/tests/dynamic-live-activity/api.node.test.ts @@ -0,0 +1,172 @@ +import { Platform } from 'react-native' +import * as React from 'react' + +const ReactTestRenderer = require('react-test-renderer') as { + act: (callback: () => void | Promise) => Promise + create: (element: React.ReactElement) => { update: (element: React.ReactElement) => void; unmount: () => void } +} + +import { getNativeVoltra, type Spec } from '../../src/VoltraModule.js' +import { + getDynamicLiveActivityDefinitionIds, + startDynamicLiveActivity, + updateDynamicLiveActivity, +} from '../../src/live-activity/dynamic-api.js' + +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' }, +})) + +jest.mock('../../src/VoltraModule.js', () => ({ getNativeVoltra: jest.fn() })) + +const mockedGetNativeVoltra = jest.mocked(getNativeVoltra) +const mockedPlatform = Platform as { OS: string } + +describe('Dynamic Live Activity client APIs', () => { + afterEach(() => { + mockedPlatform.OS = 'ios' + jest.clearAllMocks() + jest.restoreAllMocks() + }) + + it('serializes complete props and routes dynamic starts without using the legacy renderer', async () => { + const startDynamicLiveActivityNative = jest + .fn, [string, string, object]>() + .mockResolvedValue('order-123') + const startLiveActivity = jest.fn() + mockedGetNativeVoltra.mockReturnValue({ + startDynamicLiveActivity: startDynamicLiveActivityNative, + startLiveActivity, + } as unknown as Spec) + + await expect( + startDynamicLiveActivity( + 'order_finished', + { status: 'delivering', stops: [{ id: 1, complete: false }] }, + { activityName: 'order-123', deepLinkUrl: 'myapp://orders/123', channelId: 'orders', relevanceScore: 0.9 } + ) + ).resolves.toBe('order-123') + + expect(startDynamicLiveActivityNative).toHaveBeenCalledWith( + 'order_finished', + '{"status":"delivering","stops":[{"id":1,"complete":false}]}', + { + target: 'liveActivity', + activityName: 'order-123', + deepLinkUrl: 'myapp://orders/123', + channelId: 'orders', + relevanceScore: 0.9, + } + ) + expect(startLiveActivity).not.toHaveBeenCalled() + }) + + it('replaces complete props and normalizes update metadata through the dynamic native method', async () => { + const updateDynamicLiveActivityNative = jest + .fn, [string, string, object]>() + .mockResolvedValue(undefined) + const updateLiveActivity = jest.fn() + mockedGetNativeVoltra.mockReturnValue({ + updateDynamicLiveActivity: updateDynamicLiveActivityNative, + updateLiveActivity, + } as unknown as Spec) + + await updateDynamicLiveActivity( + 'order-123', + { status: 'delivered' }, + { staleDate: Date.now() - 1, relevanceScore: 2 } + ) + + expect(updateDynamicLiveActivityNative).toHaveBeenCalledWith('order-123', '{"status":"delivered"}', { + relevanceScore: 0, + }) + expect(updateLiveActivity).not.toHaveBeenCalled() + }) + + it.each([ + [{ value: undefined }, 'unsupported type undefined'], + [{ value: Number.NaN }, 'not a finite number'], + [{ value: new Date() }, 'must be a plain object'], + ])('rejects non-JSON-compatible props with a clear error: %o', async (props, message) => { + await expect(startDynamicLiveActivity('order_finished', props as never)).rejects.toThrow(message) + expect(mockedGetNativeVoltra).not.toHaveBeenCalled() + }) + + it('rejects circular props before calling native', async () => { + const props: { self?: unknown } = {} + props.self = props + + await expect(startDynamicLiveActivity('order_finished', props as never)).rejects.toThrow('circular reference') + expect(mockedGetNativeVoltra).not.toHaveBeenCalled() + }) + + it('returns the installed native definition catalog', async () => { + const nativeGetDefinitionIds = jest.fn, []>().mockResolvedValue(['delivery', 'order_finished']) + mockedGetNativeVoltra.mockReturnValue({ + getDynamicLiveActivityDefinitionIds: nativeGetDefinitionIds, + } as unknown as Spec) + + await expect(getDynamicLiveActivityDefinitionIds()).resolves.toEqual(['delivery', 'order_finished']) + }) + + it('uses iOS no-op behavior on other platforms', async () => { + mockedPlatform.OS = 'android' + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(startDynamicLiveActivity('order_finished', { status: 'pending' })).resolves.toBe('') + await expect(updateDynamicLiveActivity('order-123', { status: 'pending' })).resolves.toBeUndefined() + await expect(getDynamicLiveActivityDefinitionIds()).resolves.toEqual([]) + + expect(mockedGetNativeVoltra).not.toHaveBeenCalled() + expect(consoleError).toHaveBeenCalledTimes(3) + }) + + it('auto-starts and auto-updates the complete current props through the dynamic engine', async () => { + const startDynamicLiveActivityNative = jest + .fn, [string, string, object]>() + .mockResolvedValue('order-123') + const updateDynamicLiveActivityNative = jest + .fn, [string, string, object]>() + .mockResolvedValue(undefined) + const isLiveActivityActive = jest.fn().mockReturnValue(false) + mockedGetNativeVoltra.mockReturnValue({ + startDynamicLiveActivity: startDynamicLiveActivityNative, + updateDynamicLiveActivity: updateDynamicLiveActivityNative, + isLiveActivityActive, + onStateChanged: jest.fn(() => ({ remove: jest.fn() })), + } as unknown as Spec) + + function Activity({ status }: { status: string }): null { + // This component deliberately exists only to exercise the public hook. + require('../../src/live-activity/dynamic-api.js').useDynamicLiveActivity( + 'order_finished', + { status }, + { + activityName: 'order-123', + autoStart: true, + autoUpdate: true, + } + ) + return null + } + + let renderer: ReturnType + await ReactTestRenderer.act(async () => { + renderer = ReactTestRenderer.create(React.createElement(Activity, { status: 'pending' })) + }) + await ReactTestRenderer.act(async () => { + renderer!.update(React.createElement(Activity, { status: 'delivered' })) + }) + + expect(startDynamicLiveActivityNative).toHaveBeenCalledWith( + 'order_finished', + '{"status":"pending"}', + expect.objectContaining({ activityName: 'order-123' }) + ) + expect(updateDynamicLiveActivityNative).toHaveBeenLastCalledWith('order-123', '{"status":"delivered"}', { + relevanceScore: 0, + }) + + await ReactTestRenderer.act(async () => renderer!.unmount()) + }) +}) diff --git a/packages/ios-client/tests/setup.node.js b/packages/ios-client/tests/setup.node.js new file mode 100644 index 00000000..54a79745 --- /dev/null +++ b/packages/ios-client/tests/setup.node.js @@ -0,0 +1,2 @@ +global.__DEV__ = false +global.IS_REACT_ACT_ENVIRONMENT = true From 392d11e2ded8752f8306172c17ba2b3315f83833 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 09:33:13 +0200 Subject: [PATCH 11/24] docs(ios): document dynamic live activities --- example/app.json | 6 + .../OrderFinishedDynamicLiveActivity.tsx | 35 ++++ .../DynamicOrderFinishedLiveActivity.tsx | 33 ++++ .../live-activities/LiveActivitiesScreen.tsx | 18 ++ .../src/ios-widget/files/swift.node.test.ts | 7 + packages/ios/src/index.ts | 1 + packages/ios/src/live-activity/types.ts | 18 ++ .../docs/v2/ios/api/plugin-configuration.md | 7 + website/docs/v2/ios/development/_meta.json | 5 + .../development/developing-live-activities.md | 2 + .../development/dynamic-live-activities.md | 172 ++++++++++++++++++ .../managing-live-activities-locally.md | 2 + 12 files changed, 306 insertions(+) create mode 100644 example/components/live-activities/OrderFinishedDynamicLiveActivity.tsx create mode 100644 example/screens/live-activities/DynamicOrderFinishedLiveActivity.tsx create mode 100644 website/docs/v2/ios/development/dynamic-live-activities.md diff --git a/example/app.json b/example/app.json index 445654c6..7cfef870 100644 --- a/example/app.json +++ b/example/app.json @@ -32,6 +32,12 @@ "groupIdentifier": "group.callstackincubator.voltraexample", "keychainGroup": "$(AppIdentifierPrefix)group.callstackincubator.voltraexample", "enablePushNotifications": true, + "liveActivities": [ + { + "id": "order_finished", + "entry": "./components/live-activities/OrderFinishedDynamicLiveActivity.tsx" + } + ], "widgets": [ { "id": "weather", diff --git a/example/components/live-activities/OrderFinishedDynamicLiveActivity.tsx b/example/components/live-activities/OrderFinishedDynamicLiveActivity.tsx new file mode 100644 index 00000000..6de7e51c --- /dev/null +++ b/example/components/live-activities/OrderFinishedDynamicLiveActivity.tsx @@ -0,0 +1,35 @@ +import { Voltra, type LiveActivityEnvironment } from '@use-voltra/ios' + +type OrderFinishedProps = { + orderNumber?: string + status?: string +} + +/** A bundled Dynamic Live Activity entry used by the example app. */ +export default function OrderFinishedDynamicLiveActivity( + props: OrderFinishedProps = {}, + environment: LiveActivityEnvironment +) { + const orderNumber = props.orderNumber ?? '123' + const status = props.status ?? 'Ready for pickup' + + return { + lockScreen: { + activityBackgroundTint: '#14532D', + content: ( + + Order #{orderNumber} + {status} + {environment.isStale ? Status may be outdated : null} + + ), + }, + island: { + compact: { + leading: #{orderNumber}, + trailing: , + }, + minimal: , + }, + } +} diff --git a/example/screens/live-activities/DynamicOrderFinishedLiveActivity.tsx b/example/screens/live-activities/DynamicOrderFinishedLiveActivity.tsx new file mode 100644 index 00000000..3f0f2e13 --- /dev/null +++ b/example/screens/live-activities/DynamicOrderFinishedLiveActivity.tsx @@ -0,0 +1,33 @@ +import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react' +import { useDynamicLiveActivity } from '@use-voltra/ios-client' + +import { LiveActivityExampleComponent } from './types' + +const DynamicOrderFinishedLiveActivity: LiveActivityExampleComponent = forwardRef( + ({ autoUpdate = true, autoStart = false, onIsActiveChange }, ref) => { + const [status, setStatus] = useState('Preparing your order') + const { start, end, isActive } = useDynamicLiveActivity( + 'order_finished', + { orderNumber: '123', status }, + { activityName: 'dynamic-order-123', autoStart, autoUpdate, deepLinkUrl: '/ios/activity' } + ) + + useEffect(() => { + onIsActiveChange?.(isActive) + }, [isActive, onIsActiveChange]) + + useImperativeHandle(ref, () => ({ + start, + update: () => { + setStatus((current) => (current === 'Preparing your order' ? 'Ready for pickup' : 'Preparing your order')) + }, + end, + })) + + return null + } +) + +DynamicOrderFinishedLiveActivity.displayName = 'DynamicOrderFinishedLiveActivity' + +export default DynamicOrderFinishedLiveActivity diff --git a/example/screens/live-activities/LiveActivitiesScreen.tsx b/example/screens/live-activities/LiveActivitiesScreen.tsx index 62444d4a..f25c7c7b 100644 --- a/example/screens/live-activities/LiveActivitiesScreen.tsx +++ b/example/screens/live-activities/LiveActivitiesScreen.tsx @@ -9,6 +9,7 @@ import { ScreenLayout } from '~/components/ScreenLayout' import BasicLiveActivity from '~/screens/live-activities/BasicLiveActivity' import CompassLiveActivity from '~/screens/live-activities/CompassLiveActivity' import DeepLinksLiveActivity from '~/screens/live-activities/DeepLinksLiveActivity' +import DynamicOrderFinishedLiveActivity from '~/screens/live-activities/DynamicOrderFinishedLiveActivity' import FlightLiveActivity from '~/screens/live-activities/FlightLiveActivity' import LiquidGlassLiveActivity from '~/screens/live-activities/LiquidGlassLiveActivity' import MusicPlayerLiveActivity from '~/screens/live-activities/MusicPlayerLiveActivity' @@ -22,6 +23,7 @@ type ActivityKey = | 'stylesheet' | 'glass' | 'deepLinks' + | 'dynamicOrderFinished' | 'flight' | 'workout' | 'compass' @@ -44,6 +46,10 @@ const ACTIVITY_METADATA: Record(null) const glassRef = useRef(null) const deepLinksRef = useRef(null) + const dynamicOrderFinishedRef = useRef(null) const flightRef = useRef(null) const workoutRef = useRef(null) const compassRef = useRef(null) @@ -103,6 +112,7 @@ export default function LiveActivitiesScreen() { stylesheet: stylesheetRef, glass: glassRef, deepLinks: deepLinksRef, + dynamicOrderFinished: dynamicOrderFinishedRef, flight: flightRef, workout: workoutRef, compass: compassRef, @@ -134,6 +144,10 @@ export default function LiveActivitiesScreen() { (isActive: boolean) => handleStatusChange('deepLinks', isActive), [handleStatusChange] ) + const handleDynamicOrderFinishedStatusChange = useCallback( + (isActive: boolean) => handleStatusChange('dynamicOrderFinished', isActive), + [handleStatusChange] + ) const handleFlightStatusChange = useCallback( (isActive: boolean) => handleStatusChange('flight', isActive), [handleStatusChange] @@ -208,6 +222,10 @@ export default function LiveActivitiesScreen() { + diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts index d1b73298..35d7a1b2 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts @@ -1,3 +1,5 @@ +import { getDynamicLiveActivityAttributesType } from '../../../../../ios/src/live-activity/dynamic' + import type { DetectedIOSWidget } from '../clientRendered' import { __test__ } from './swift' @@ -86,9 +88,14 @@ describe('Dynamic Live Activity Swift generation', () => { const bundle = __test__.generateWidgetBundleSwift([], liveActivities) expect(types).toContain('VoltraOrderFinishedLiveActivityAttributes') + expect(types).toContain( + `public struct ${getDynamicLiveActivityAttributesType('order_finished')}: ActivityAttributes` + ) expect(types).toContain('VoltraDriverArrivedLiveActivityAttributes') + expect(types).toContain('public typealias ContentState = VoltraDynamicLiveActivityContentState') expect(types).toContain('public let name: String') expect(types).toContain('public let deepLinkUrl: String?') + expect(types).toContain('public static let attributesTypeName = "VoltraOrderFinishedLiveActivityAttributes"') expect(types).toContain('import VoltraWidget') expect(types).toContain('VoltraDynamicLiveActivityCatalog: VoltraDynamicLiveActivityCatalogLookup') expect(types).toContain( diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts index ec6883cd..ecb574b2 100644 --- a/packages/ios/src/index.ts +++ b/packages/ios/src/index.ts @@ -11,6 +11,7 @@ export { renderLiveActivityToJson, renderLiveActivityToString } from './live-act export { getDynamicLiveActivityAttributesType } from './live-activity/dynamic.js' export type { DismissalPolicy, + LiveActivityEnvironment, LiveActivityJson, LiveActivityVariants, LiveActivityVariantsJson, diff --git a/packages/ios/src/live-activity/types.ts b/packages/ios/src/live-activity/types.ts index b9d4cd97..f4ac8e2d 100644 --- a/packages/ios/src/live-activity/types.ts +++ b/packages/ios/src/live-activity/types.ts @@ -1,7 +1,25 @@ import type { ReactNode } from 'react' +import type { WidgetEnvironment } from '@use-voltra/core' + import type { VoltraNodeJson } from '../types.js' +/** + * Runtime context supplied to a Dynamic Live Activity entry. + * + * Unlike `WidgetEnvironment`, this intentionally omits Home Screen-only fields + * such as `widgetFamily`, `showsWidgetContainerBackground`, and `configuration`. + */ +export type LiveActivityEnvironment = Pick< + WidgetEnvironment, + 'date' | 'colorScheme' | 'locale' | 'widgetRenderingMode' | 'build' +> & { + /** Whether ActivityKit currently considers this activity stale. */ + isStale: boolean + /** iOS 18+ ActivityKit family, when the activity is rendered in one. */ + activityFamily?: string +} + /** * Live Activity variants - defines content for different states */ diff --git a/website/docs/v2/ios/api/plugin-configuration.md b/website/docs/v2/ios/api/plugin-configuration.md index 2d44a4d2..833a9bed 100644 --- a/website/docs/v2/ios/api/plugin-configuration.md +++ b/website/docs/v2/ios/api/plugin-configuration.md @@ -39,6 +39,7 @@ App Group identifier for sharing data between your app and the widget extension. - Share images between your app and the extension - Use image preloading features - Update entry-based Dynamic Widgets with runtime props +- Use Dynamic Live Activities **Format:** Must start with `group.` (e.g., `group.your.bundle.identifier`) @@ -49,6 +50,12 @@ Enable server-side updates for Live Activities via Apple Push Notification Servi **Type:** `boolean` **Default:** `false` +### `liveActivities` (optional, experimental) + +Bundled Dynamic Live Activity definitions. Every declaration has a stable `id` and an `entry` module that default-exports the renderer function. IDs use only alphanumeric characters and underscores, and are unique within this collection; they are separate from Dynamic Widget IDs. + +When this array is non-empty, `groupIdentifier` is required. See [Dynamic Live Activities](../development/dynamic-live-activities) for the entry signature, lifecycle APIs, and push payload contract. + ### `deploymentTarget` (optional) iOS deployment target version for the widget extension. If not provided, defaults to `17.0`. This allows the widget extension to have its own deployment target independent of the main app. diff --git a/website/docs/v2/ios/development/_meta.json b/website/docs/v2/ios/development/_meta.json index 71de8d23..a42f6ed9 100644 --- a/website/docs/v2/ios/development/_meta.json +++ b/website/docs/v2/ios/development/_meta.json @@ -9,6 +9,11 @@ "name": "developing-live-activities", "label": "Developing Live Activities" }, + { + "type": "file", + "name": "dynamic-live-activities", + "label": "Dynamic Live Activities (experimental)" + }, { "type": "file", "name": "developing-widgets", diff --git a/website/docs/v2/ios/development/developing-live-activities.md b/website/docs/v2/ios/development/developing-live-activities.md index de9920e4..37878e68 100644 --- a/website/docs/v2/ios/development/developing-live-activities.md +++ b/website/docs/v2/ios/development/developing-live-activities.md @@ -2,6 +2,8 @@ Voltra provides APIs that make building and testing Live Activities easier during development. +For bundled, on-device definitions that receive generic props rather than a rendered UI payload, see [Dynamic Live Activities](./dynamic-live-activities). + ## Supported variants Live Activities in iOS can appear in different contexts, and Voltra supports defining UI variants for each of these contexts. For detailed information about Live Activity design guidelines, see the [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/live-activities). diff --git a/website/docs/v2/ios/development/dynamic-live-activities.md b/website/docs/v2/ios/development/dynamic-live-activities.md new file mode 100644 index 00000000..ed80bc31 --- /dev/null +++ b/website/docs/v2/ios/development/dynamic-live-activities.md @@ -0,0 +1,172 @@ +# Dynamic Live Activities + +:::warning Experimental feature + +Dynamic Live Activities and their public APIs are experimental in V1. They are separate from both legacy Live Activities and [Dynamic Widgets](./dynamic-widgets): they have their own configuration collection, generated ActivityKit types, Metro route, runtime registry, and release bundles. + +::: + +A legacy Live Activity sends a fully rendered Voltra UI in every update. A Dynamic Live Activity bundles its rendering definition in the app and receives only a complete, JSON-compatible props record. This keeps update payloads small, but makes the definition ID and its props contract a compatibility boundary between the app and the push producer. + +## Configure a definition + +Install the iOS packages and `@use-voltra/metro`, then wrap the app Metro configuration as shown in [Dynamic Widgets: Set up Metro](./dynamic-widgets#set-up-metro). Declare each Dynamic Live Activity in the iOS Voltra plugin. An App Group is mandatory, even if updates arrive only through ActivityKit pushes, because the extension uses it to deliver diagnostic render failures to the app. + +```json title="app.json" +{ + "expo": { + "ios": { "bundleIdentifier": "com.example.orders" }, + "plugins": [ + [ + "@use-voltra/ios-client", + { + "groupIdentifier": "group.com.example.orders", + "enablePushNotifications": true, + "liveActivities": [ + { + "id": "order_finished", + "entry": "./live-activities/order-finished.tsx" + } + ] + } + ] + ] + } +} +``` + +IDs contain only letters, numbers, and underscores. They are unique only within `liveActivities`, so a Dynamic Widget may use the same ID. Run Expo Prebuild (or Voltra Apply) and make a new native build after changing this configuration. + +The generated ActivityKit attributes type turns underscores into UpperCamelCase. The declaration above produces `VoltraOrderFinishedLiveActivityAttributes`; this exact name is required for a push-to-start payload. + +## Write the entry + +The entry must default-export a function with the signature `(props, environment) => LiveActivityVariants`. It returns the same Lock Screen, Dynamic Island, and optional supplemental-family shape as a legacy Live Activity. + +```tsx title="live-activities/order-finished.tsx" +import { Voltra, type LiveActivityEnvironment } from '@use-voltra/ios' + +type OrderFinishedProps = { + orderNumber?: string + status?: string +} + +export default function OrderFinished( + props: OrderFinishedProps = {}, + environment: LiveActivityEnvironment +) { + return { + lockScreen: ( + + Order #{props.orderNumber ?? '123'} + {props.status ?? 'Preparing'} + {environment.isStale ? Status may be outdated : null} + + ), + island: { + compact: { + leading: Order, + trailing: , + }, + }, + } +} +``` + +`LiveActivityEnvironment` provides `date`, `colorScheme`, `locale`, `widgetRenderingMode`, `build`, `isStale`, and, for applicable iOS 18+ activity families, `activityFamily`. It deliberately does not expose Home Screen widget fields such as `widgetFamily`, `showsWidgetContainerBackground`, or `configuration`. + +Props are opaque in V1. They must be a complete JSON-compatible object (strings, finite numbers, booleans, `null`, arrays, and plain nested objects). Each update replaces the whole record; it does not merge it. Voltra does not generate definition-specific prop types or validate that the producer supplied the props this entry expects. + +## Start and update locally + +Use the explicit Dynamic APIs—do not use `startLiveActivity`, `updateLiveActivity`, or `useLiveActivity` for a bundled definition. + +```tsx +import { + getDynamicLiveActivityDefinitionIds, + startDynamicLiveActivity, + updateDynamicLiveActivity, + useDynamicLiveActivity, +} from '@use-voltra/ios-client' + +const activityId = await startDynamicLiveActivity( + 'order_finished', + { orderNumber: '123', status: 'Preparing' }, + { activityName: 'order-123', deepLinkUrl: 'myapp://orders/123' } +) + +await updateDynamicLiveActivity(activityId, { + orderNumber: '123', + status: 'Ready for pickup', +}) + +const availableDefinitions = await getDynamicLiveActivityDefinitionIds() + +// In a React component: +const activity = useDynamicLiveActivity('order_finished', { orderNumber: '123', status: 'Preparing' }, { + activityName: 'order-123', + autoStart: true, + autoUpdate: true, +}) +``` + +The hook’s auto-update also sends the complete latest props object. `stopLiveActivity`, `endAllLiveActivities`, active-state checks, and shared lifecycle operations work across both engines. Updating through the wrong engine-specific API fails with a renderer-mismatch error. Local starts keep the existing replacement behavior: when replacement is enabled, an existing activity with the same name is ended across both engines. + +For Fast Refresh in development, keep the generated host-graph import and enable targeted reload once at app startup: + +```ts +import '@use-voltra/live-activity-hot-reload' +import { enableDynamicLiveActivityHotReload } from '@use-voltra/ios-client' + +enableDynamicLiveActivityHotReload() +``` + +The changed definition alone is reloaded. A development bundle is served from `/voltra/live-activities/.bundle`; release bundles use the separate `voltra-live-activity-.bundle` asset prefix. + +## Remote updates and payloads + +Push-to-start requires the generated type name in `attributes-type`. For `order_finished`, the Voltra-specific ActivityKit fields are exactly: + +```json +{ + "attributes-type": "VoltraOrderFinishedLiveActivityAttributes", + "attributes": { + "name": "order-123", + "deepLinkUrl": "myapp://orders/123" + }, + "content-state": { + "props": { + "orderNumber": "123", + "status": "Preparing" + } + } +} +``` + +`deepLinkUrl` is optional. Update and end pushes omit static `attributes`; an update replaces the complete `content-state.props` record. Standard ActivityKit/APNs fields—including timestamps, alerts, stale dates, relevance scores, dismissal dates, and channel fields—continue to use their normal semantics. + +For server producers, `DynamicLiveActivityProps`, `DynamicLiveActivityContentState`, and `getDynamicLiveActivityAttributesType(definitionId)` are exported from both `@use-voltra/ios` and `@use-voltra/ios-server`. V1 intentionally has no dynamic render or payload-construction helper: props go directly into ActivityKit content state. + +Voltra checks the existing 4 KB ActivityKit limit for encoded attributes plus state on a local start, and for encoded content state on a local update. A server producer remains responsible for the size of its complete APNs payload. + +Existing `activityPushToStartTokenReceived` and `activityTokenReceived` event contracts do not change. The former remains app-wide; an update token already targets its activity instance and carries its existing activity name. Register `getDynamicLiveActivityDefinitionIds()` alongside the unchanged push-to-start token so your server can route the correct engine and definition. + +Rendering failures are diagnostic events, delivered as `dynamicLiveActivityRenderFailed` through `addVoltraListener`. Each event includes the common `type`, `source`, and `timestamp` fields plus `activityName`, `definitionId`, and a sanitized `message`; it never includes props or tokens. + +```ts +import { addVoltraListener } from '@use-voltra/ios-client' + +const subscription = addVoltraListener('dynamicLiveActivityRenderFailed', (event) => { + console.warn(`Could not render ${event.definitionId} for ${event.activityName}: ${event.message}`) +}) +``` + +## Rollout and compatibility + +- An older app can only accept a push-to-start for a definition whose generated attributes type and ActivityKit configuration it contains. Supporting another Dynamic Live Activity is not enough. +- An undeclared definition has no generated type, configuration, catalog entry, or bundle; local APIs reject it and ActivityKit does not create it remotely. +- Keep a definition bundled until all activities using it have ended. End active instances before removing its declaration. +- Treat an ID as a versioned rendering-and-props contract. Use a new ID such as `order_finished_v2` for breaking prop changes; incompatible props under the old ID are producer error. +- A broadcast channel cannot tailor format per recipient. Use Dynamic Live Activities only on channels whose recipients support the same definition; otherwise retain the legacy format. +- A missing/corrupt bundle or late renderer failure leaves a remotely created activity active but empty, records a failure, and can recover on a later successful update. V1 does not cache the last successful UI. +- ActivityKit can create duplicate remote starts with the same name; V1 does not reconcile them. This differs from local replacement behavior. diff --git a/website/docs/v2/ios/development/managing-live-activities-locally.md b/website/docs/v2/ios/development/managing-live-activities-locally.md index bd96dba4..25b6366f 100644 --- a/website/docs/v2/ios/development/managing-live-activities-locally.md +++ b/website/docs/v2/ios/development/managing-live-activities-locally.md @@ -13,6 +13,8 @@ Managing Live Activities locally involves four main phases: Voltra offers both imperative APIs for direct control and React hooks for seamless integration with your components. +This page documents the legacy server-rendered engine. For the separate experimental engine that bundles a definition with the app and updates it with props, see [Dynamic Live Activities](./dynamic-live-activities). + ## Imperative APIs The imperative APIs provide direct, programmatic control over Live Activities. These are the core functions you'll use to manage Live Activity lifecycles. From 125f687f967f5ab00d6ab1adc75900b77e8c217e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 10:00:46 +0200 Subject: [PATCH 12/24] fix(ios): decouple generated live activity types --- .../src/ios-widget/files/swift.node.test.ts | 17 +-- .../expo-plugin/src/ios-widget/files/swift.ts | 100 +------------ .../ios/app/VoltraLiveActivityManager.swift | 2 +- .../ios/app/VoltraLiveActivityService.swift | 33 ++--- .../VoltraDynamicLiveActivityOperations.swift | 13 +- .../VoltraDynamicLiveActivityRegistry.swift | 139 ++++++++++++++++++ .../VoltraDynamicLiveActivityRenderer.swift | 2 +- .../VoltraDynamicLiveActivityTypes.swift | 15 +- 8 files changed, 177 insertions(+), 144 deletions(-) create mode 100644 packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRegistry.swift diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts index 35d7a1b2..c53ab3d0 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts @@ -97,22 +97,15 @@ describe('Dynamic Live Activity Swift generation', () => { expect(types).toContain('public let deepLinkUrl: String?') expect(types).toContain('public static let attributesTypeName = "VoltraOrderFinishedLiveActivityAttributes"') expect(types).toContain('import VoltraWidget') - expect(types).toContain('VoltraDynamicLiveActivityCatalog: VoltraDynamicLiveActivityCatalogLookup') + expect(types).toContain('@objc(VoltraGeneratedDynamicLiveActivityRegistration)') + expect(types).toContain('public final class VoltraGeneratedDynamicLiveActivityRegistration: NSObject') expect(types).toContain( - 'VoltraDriverArrivedLiveActivityAttributes.self, VoltraOrderFinishedLiveActivityAttributes.self' + 'VoltraDynamicLiveActivityRegistry.shared.register(VoltraDriverArrivedLiveActivityAttributes.self)' ) - expect(types).toContain('VoltraDynamicLiveActivityOperations.create(') - expect(types).toContain('VoltraDynamicLiveActivityOperations.update(') - expect(types).toContain('VoltraDynamicLiveActivityOperations.endAll(') - expect(types).toContain('public static func definitionIds() -> [String]') - expect(types).toContain('definitions.map(\\.definitionId)') - expect(types).toContain('public static func startObserving(with observer: VoltraDynamicLiveActivityObserver) async') - expect(types).toContain('await observer.observe(VoltraDriverArrivedLiveActivityAttributes.self)') - expect(types).toContain('await observer.observe(VoltraOrderFinishedLiveActivityAttributes.self)') - expect(types).toContain('public static func reload(definitionIds: Set?) async') expect(types).toContain( - 'VoltraDynamicLiveActivityOperations.reload(VoltraDriverArrivedLiveActivityAttributes.self)' + 'VoltraDynamicLiveActivityRegistry.shared.register(VoltraOrderFinishedLiveActivityAttributes.self)' ) + expect(types).not.toContain('VoltraDynamicLiveActivityCatalog') expect(configurations).toContain('VoltraDynamicLiveActivityRenderer.lockScreen(definitionId: "driver_arrived"') expect(configurations).toContain('VoltraDynamicLiveActivityRenderer.dynamicIsland(definitionId: "order_finished"') expect(configurations).toContain('.supplementalActivityFamilies([.small, .medium])') 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 a0ecfbf1..f6f0053a 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 @@ -547,9 +547,6 @@ function generateDefaultWidgetBundleSwift(): string { /** Generates the Dynamic Live Activity types shared by the app and extension targets. */ function generateDynamicLiveActivityTypesSwift(liveActivities: IOSDynamicLiveActivityConfig[]): string { const definitions = [...liveActivities].sort((left, right) => left.id.localeCompare(right.id)) - const catalogEntries = definitions - .map((liveActivity) => `${getDynamicLiveActivityAttributesType(liveActivity.id)}.self`) - .join(', ') const typeDefinitions = definitions.map(generateDynamicLiveActivitySwift).map(indentGeneratedSwift).join('\n\n') const header = dedent` @@ -568,101 +565,16 @@ function generateDynamicLiveActivityTypesSwift(liveActivities: IOSDynamicLiveAct import Voltra #endif - public enum VoltraDynamicLiveActivityCatalog: VoltraDynamicLiveActivityCatalogLookup { - public static let definitions: [any VoltraDynamicLiveActivityDefinition.Type] = [${catalogEntries}] - - public static func contains(_ definitionId: String) -> Bool { - definitions.contains { $0.definitionId == definitionId } - } - - public static func create(_ request: VoltraDynamicLiveActivityCreateRequest) async throws -> Bool { - switch request.definitionId { -${definitions - .map( - (liveActivity) => ` case "${escapeForSwiftStringLiteral(liveActivity.id)}": - try await VoltraDynamicLiveActivityOperations.create(${getDynamicLiveActivityAttributesType( - liveActivity.id - )}.self, request: request) - return true` - ) - .join('\n')} - default: - return false - } - } - - public static func update(byName name: String, request: VoltraDynamicLiveActivityUpdateRequest) async throws -> Bool { + @objc(VoltraGeneratedDynamicLiveActivityRegistration) + public final class VoltraGeneratedDynamicLiveActivityRegistration: NSObject { + @objc public static func registerDefinitions() { ${definitions .map( (liveActivity) => - ` if await VoltraDynamicLiveActivityOperations.update(${getDynamicLiveActivityAttributesType( + ` VoltraDynamicLiveActivityRegistry.shared.register(${getDynamicLiveActivityAttributesType( liveActivity.id - )}.self, byName: name, request: request) { return true }` + )}.self)` ) - .join('\n')} - return false - } - - public static func end(byName name: String, dismissalPolicy: ActivityUIDismissalPolicy) async -> Bool { -${definitions - .map( - (liveActivity) => - ` if await VoltraDynamicLiveActivityOperations.end(${getDynamicLiveActivityAttributesType( - liveActivity.id - )}.self, byName: name, dismissalPolicy: dismissalPolicy) { return true }` - ) - .join('\n')} - return false - } - - public static func endAll(dismissalPolicy: ActivityUIDismissalPolicy) async { -${definitions - .map( - (liveActivity) => - ` await VoltraDynamicLiveActivityOperations.endAll(${getDynamicLiveActivityAttributesType( - liveActivity.id - )}.self, dismissalPolicy: dismissalPolicy)` - ) - .join('\n')} - } - - public static func activities() -> [VoltraDynamicLiveActivityReference] { - ${ - definitions.length === 0 - ? 'return []' - : `[${definitions - .map( - (liveActivity) => - `VoltraDynamicLiveActivityOperations.activities(${getDynamicLiveActivityAttributesType( - liveActivity.id - )}.self)` - ) - .join(', ')}].flatMap { $0 }` - } - } - - public static func definitionIds() -> [String] { - definitions.map(\.definitionId) - } - - public static func startObserving(with observer: VoltraDynamicLiveActivityObserver) async { -${definitions - .map( - (liveActivity) => ` await observer.observe(${getDynamicLiveActivityAttributesType(liveActivity.id)}.self)` - ) - .join('\n')} - } - - public static func reload(definitionIds: Set?) async { -${definitions - .map((liveActivity) => { - const definitionId = escapeForSwiftStringLiteral(liveActivity.id) - return ` if definitionIds?.contains("${definitionId}") != false { - await VoltraDynamicLiveActivityOperations.reload(${getDynamicLiveActivityAttributesType( - liveActivity.id - )}.self) - }` - }) .join('\n')} } } @@ -682,7 +594,7 @@ function generateDynamicLiveActivitySwift(liveActivity: IOSDynamicLiveActivityCo public let name: String public let deepLinkUrl: String? - public init(name: String, deepLinkUrl: String? = nil) { + public init(name: String, deepLinkUrl: String?) { self.name = name self.deepLinkUrl = deepLinkUrl } diff --git a/packages/ios-client/ios/app/VoltraLiveActivityManager.swift b/packages/ios-client/ios/app/VoltraLiveActivityManager.swift index 0cd05f47..a9785565 100644 --- a/packages/ios-client/ios/app/VoltraLiveActivityManager.swift +++ b/packages/ios-client/ios/app/VoltraLiveActivityManager.swift @@ -85,7 +85,7 @@ public actor VoltraLiveActivityManager { startPushToStartObservation() Task { [weak self, dynamicObserver] in guard await self?.currentlyObserving() == true else { return } - await VoltraDynamicLiveActivityCatalog.startObserving(with: dynamicObserver) + await VoltraDynamicLiveActivityRegistry.shared.startObserving(with: dynamicObserver) } } diff --git a/packages/ios-client/ios/app/VoltraLiveActivityService.swift b/packages/ios-client/ios/app/VoltraLiveActivityService.swift index cb2f3f02..2984aa92 100644 --- a/packages/ios-client/ios/app/VoltraLiveActivityService.swift +++ b/packages/ios-client/ios/app/VoltraLiveActivityService.swift @@ -122,7 +122,7 @@ public class VoltraLiveActivityService { /// Check if an activity with the given name exists across both types public func isActivityActive(name: String) -> Bool { - findActivity(byName: name) != nil || VoltraDynamicLiveActivityCatalog.activities().contains { $0.name == name } + findActivity(byName: name) != nil || VoltraDynamicLiveActivityRegistry.shared.activities().contains { $0.name == name } } /// The unified list intentionally erases each engine's concrete attributes type. @@ -131,7 +131,7 @@ public class VoltraLiveActivityService { let legacy = getAllActivities().map { VoltraDynamicLiveActivityReference(id: $0.id, name: $0.attributes.name, definitionId: "legacy") } - return legacy + VoltraDynamicLiveActivityCatalog.activities() + return legacy + VoltraDynamicLiveActivityRegistry.shared.activities() } public func latestActivityId() -> String? { @@ -141,7 +141,7 @@ public class VoltraLiveActivityService { /// The installed capability list is generated during prebuild and does not /// depend on Metro, the app group, or a server connection. public func dynamicLiveActivityDefinitionIds() -> [String] { - VoltraDynamicLiveActivityCatalog.definitionIds() + VoltraDynamicLiveActivityRegistry.shared.definitionIds() } // MARK: - Create Operations @@ -221,7 +221,7 @@ public class VoltraLiveActivityService { request: UpdateActivityRequest ) async throws { guard let activity = findActivity(byName: name) else { - if VoltraDynamicLiveActivityCatalog.activities().contains(where: { $0.name == name }) { + if VoltraDynamicLiveActivityRegistry.shared.activities().contains(where: { $0.name == name }) { throw VoltraLiveActivityError.rendererMismatch } throw VoltraLiveActivityError.notFound @@ -256,10 +256,10 @@ public class VoltraLiveActivityService { if let activity = findActivity(byName: name) { await endActivity(activity, dismissalPolicy: dismissalPolicy) // Names can collide across engines after a remote start. Shared ending covers both. - _ = await VoltraDynamicLiveActivityCatalog.end(byName: name, dismissalPolicy: dismissalPolicy) + _ = await VoltraDynamicLiveActivityRegistry.shared.end(byName: name, dismissalPolicy: dismissalPolicy) return } - guard await VoltraDynamicLiveActivityCatalog.end(byName: name, dismissalPolicy: dismissalPolicy) else { + guard await VoltraDynamicLiveActivityRegistry.shared.end(byName: name, dismissalPolicy: dismissalPolicy) else { throw VoltraLiveActivityError.notFound } } @@ -272,7 +272,7 @@ public class VoltraLiveActivityService { for activity in activities { await endActivity(activity) } - _ = await VoltraDynamicLiveActivityCatalog.end(byName: name, dismissalPolicy: .immediate) + _ = await VoltraDynamicLiveActivityRegistry.shared.end(byName: name, dismissalPolicy: .immediate) } /// End all Voltra Live Activities @@ -282,7 +282,7 @@ public class VoltraLiveActivityService { for activity in activities { await endActivity(activity) } - await VoltraDynamicLiveActivityCatalog.endAll(dismissalPolicy: .immediate) + await VoltraDynamicLiveActivityRegistry.shared.endAll(dismissalPolicy: .immediate) } // MARK: - Dynamic operations @@ -290,7 +290,7 @@ public class VoltraLiveActivityService { public func createDynamicActivity(_ request: VoltraDynamicLiveActivityCreateRequest) async throws -> String { guard Self.isSupported() else { throw VoltraLiveActivityError.unsupportedOS } guard Self.areActivitiesEnabled() else { throw VoltraLiveActivityError.liveActivitiesNotEnabled } - guard VoltraDynamicLiveActivityCatalog.contains(request.definitionId) else { + guard VoltraDynamicLiveActivityRegistry.shared.contains(request.definitionId) else { throw VoltraDynamicLiveActivityError.unknownDefinition(request.definitionId) } do { @@ -313,7 +313,7 @@ public class VoltraLiveActivityService { if request.name.isEmpty == false { try await endActivities(byName: request.name) } - guard try await VoltraDynamicLiveActivityCatalog.create(request) else { + guard try await VoltraDynamicLiveActivityRegistry.shared.create(request) != nil else { throw VoltraDynamicLiveActivityError.unknownDefinition(request.definitionId) } return request.name @@ -321,13 +321,10 @@ public class VoltraLiveActivityService { public func updateDynamicActivity(byName name: String, request: VoltraDynamicLiveActivityUpdateRequest) async throws { guard Self.isSupported() else { throw VoltraLiveActivityError.unsupportedOS } - if findActivity(byName: name) != nil { - throw VoltraDynamicLiveActivityError.rendererMismatch - } try VoltraDynamicLiveActivityPayloadValidator.validateContentState(request.props) - guard try await VoltraDynamicLiveActivityCatalog.update(byName: name, request: request) else { - throw VoltraLiveActivityError.notFound - } + if await VoltraDynamicLiveActivityRegistry.shared.update(byName: name, request: request) { return } + if findActivity(byName: name) != nil { throw VoltraDynamicLiveActivityError.rendererMismatch } + throw VoltraLiveActivityError.notFound } /// Refetch and re-evaluate only invalidated Dynamic Live Activity definitions, @@ -339,7 +336,7 @@ public class VoltraLiveActivityService { let ids = requested ?? Set(dynamicLiveActivityDefinitionIds()) var refreshed = Set() for definitionId in ids.sorted() { - guard VoltraDynamicLiveActivityCatalog.contains(definitionId) else { continue } + guard VoltraDynamicLiveActivityRegistry.shared.contains(definitionId) else { continue } do { let source = try VoltraDynamicLiveActivityBundleSource.load(definitionId: definitionId) guard VoltraJSRenderer.evaluateLiveActivityBundle(source: source, definitionId: definitionId) else { @@ -352,7 +349,7 @@ public class VoltraLiveActivityService { VoltraLogger.activity.error("Failed to refresh Dynamic Live Activity definition '\(definitionId)': \(error)") } } - await VoltraDynamicLiveActivityCatalog.reload(definitionIds: refreshed) + await VoltraDynamicLiveActivityRegistry.shared.reload(definitionIds: refreshed) #endif } diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift index 0b49b9d9..37b1f95e 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift @@ -30,14 +30,19 @@ public enum VoltraDynamicLiveActivityOperations { public static func create( _: Attributes.Type, request: VoltraDynamicLiveActivityCreateRequest - ) async throws { + ) async throws -> VoltraDynamicLiveActivityReference { let attributes = Attributes(name: request.name, deepLinkUrl: request.deepLinkUrl) let state = VoltraDynamicLiveActivityContentState(props: request.props) - _ = try Activity.request( + let activity = try Activity.request( attributes: attributes, content: ActivityContent(state: state, staleDate: request.staleDate, relevanceScore: request.relevanceScore), pushType: request.pushType ) + return VoltraDynamicLiveActivityReference( + id: activity.id, + name: activity.attributes.name, + definitionId: Attributes.definitionId + ) } public static func update( @@ -92,8 +97,8 @@ public enum VoltraDynamicLiveActivityOperations { for activity in Activity.activities { await activity.update(ActivityContent( state: activity.content.state, - staleDate: nil, - relevanceScore: 0.0 + staleDate: activity.content.staleDate, + relevanceScore: activity.content.relevanceScore )) } } diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRegistry.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRegistry.swift new file mode 100644 index 00000000..e0b67412 --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRegistry.swift @@ -0,0 +1,139 @@ +import ActivityKit +import Foundation + +/// Type-erased operations for one generated ActivityKit attributes type. +/// +/// Generated source registers descriptors at runtime. Pod-owned code only +/// depends on this registry and never names a type compiled into the host app. +private struct VoltraDynamicLiveActivityDescriptor { + let definitionId: String + let create: (VoltraDynamicLiveActivityCreateRequest) async throws -> VoltraDynamicLiveActivityReference + let update: (String, VoltraDynamicLiveActivityUpdateRequest) async -> Bool + let end: (String, ActivityUIDismissalPolicy) async -> Bool + let endAll: (ActivityUIDismissalPolicy) async -> Void + let activities: () -> [VoltraDynamicLiveActivityReference] + let observe: (VoltraDynamicLiveActivityObserver) async -> Void + let reload: () async -> Void + + init(_ attributes: Attributes.Type) { + definitionId = Attributes.definitionId + create = { try await VoltraDynamicLiveActivityOperations.create(attributes, request: $0) } + update = { await VoltraDynamicLiveActivityOperations.update(attributes, byName: $0, request: $1) } + end = { await VoltraDynamicLiveActivityOperations.end(attributes, byName: $0, dismissalPolicy: $1) } + endAll = { await VoltraDynamicLiveActivityOperations.endAll(attributes, dismissalPolicy: $0) } + activities = { VoltraDynamicLiveActivityOperations.activities(attributes) } + observe = { await $0.observe(attributes) } + reload = { await VoltraDynamicLiveActivityOperations.reload(attributes) } + } +} + +/// Runtime-owned catalog populated by the generated host source. +/// +/// `VoltraGeneratedDynamicLiveActivityRegistration` has a fixed Objective-C +/// runtime name in both the app and extension products. This lets the pod ask +/// the host target to register its generated types without creating a reverse +/// compile-time dependency from the pod to the host. +public final class VoltraDynamicLiveActivityRegistry { + public static let shared = VoltraDynamicLiveActivityRegistry() + + private let lock = NSLock() + private var descriptors: [String: VoltraDynamicLiveActivityDescriptor] = [:] + private var attemptedGeneratedRegistration = false + + private init() {} + + public func register(_ attributes: Attributes.Type) { + lock.lock() + descriptors[Attributes.definitionId] = VoltraDynamicLiveActivityDescriptor(attributes) + lock.unlock() + } + + public func contains(_ definitionId: String) -> Bool { + ensureGeneratedRegistration() + lock.lock() + defer { lock.unlock() } + return descriptors[definitionId] != nil + } + + public func definitionIds() -> [String] { + ensureGeneratedRegistration() + lock.lock() + defer { lock.unlock() } + return descriptors.keys.sorted() + } + + public func create(_ request: VoltraDynamicLiveActivityCreateRequest) async throws -> VoltraDynamicLiveActivityReference? { + guard let descriptor = descriptor(for: request.definitionId) else { return nil } + return try await descriptor.create(request) + } + + public func update(byName name: String, request: VoltraDynamicLiveActivityUpdateRequest) async -> Bool { + for descriptor in descriptorSnapshot() { + if await descriptor.update(name, request) { return true } + } + return false + } + + public func end(byName name: String, dismissalPolicy: ActivityUIDismissalPolicy) async -> Bool { + var ended = false + for descriptor in descriptorSnapshot() { + if await descriptor.end(name, dismissalPolicy) { ended = true } + } + return ended + } + + public func endAll(dismissalPolicy: ActivityUIDismissalPolicy) async { + for descriptor in descriptorSnapshot() { + await descriptor.endAll(dismissalPolicy) + } + } + + public func activities() -> [VoltraDynamicLiveActivityReference] { + descriptorSnapshot().flatMap { $0.activities() } + } + + public func startObserving(with observer: VoltraDynamicLiveActivityObserver) async { + for descriptor in descriptorSnapshot() { + await descriptor.observe(observer) + } + } + + public func reload(definitionIds: Set?) async { + for descriptor in descriptorSnapshot() where definitionIds?.contains(descriptor.definitionId) != false { + await descriptor.reload() + } + } + + private func descriptor(for definitionId: String) -> VoltraDynamicLiveActivityDescriptor? { + ensureGeneratedRegistration() + lock.lock() + defer { lock.unlock() } + return descriptors[definitionId] + } + + private func descriptorSnapshot() -> [VoltraDynamicLiveActivityDescriptor] { + ensureGeneratedRegistration() + lock.lock() + defer { lock.unlock() } + return descriptors.values.sorted { $0.definitionId < $1.definitionId } + } + + private func ensureGeneratedRegistration() { + lock.lock() + guard !attemptedGeneratedRegistration else { + lock.unlock() + return + } + attemptedGeneratedRegistration = true + lock.unlock() + + let className = "VoltraGeneratedDynamicLiveActivityRegistration" + let selector = NSSelectorFromString("registerDefinitions") + guard let registration = NSClassFromString(className) as? NSObject.Type, + registration.responds(to: selector) + else { + return + } + registration.perform(selector) + } +} diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift index d7bf627a..749c20cb 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift @@ -57,7 +57,7 @@ public enum VoltraDynamicLiveActivityRenderer { locale: Locale = .current, widgetRenderingMode: WidgetRenderingMode = .fullColor ) -> VoltraDynamicLiveActivityResolvedContent { - guard VoltraDynamicLiveActivityCatalog.contains(definitionId) else { + guard VoltraDynamicLiveActivityRegistry.shared.contains(definitionId) else { logFailure(definitionId: definitionId, activityName: context.attributes.name, message: "Definition is missing from the installed catalog") return .empty } diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift index 37e37da6..6174188a 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift @@ -84,19 +84,6 @@ public enum VoltraDynamicLiveActivityError: Error { var name: String { get } var deepLinkUrl: String? { get } - } - - /// Lets app-side lifecycle code check the generated catalog without coupling the - /// shared renderer to a particular generated file. - public protocol VoltraDynamicLiveActivityCatalogLookup { - static func contains(_ definitionId: String) -> Bool - static func create(_ request: VoltraDynamicLiveActivityCreateRequest) async throws -> Bool - static func update(byName name: String, request: VoltraDynamicLiveActivityUpdateRequest) async throws -> Bool - static func end(byName name: String, dismissalPolicy: ActivityUIDismissalPolicy) async -> Bool - static func endAll(dismissalPolicy: ActivityUIDismissalPolicy) async - static func activities() -> [VoltraDynamicLiveActivityReference] - static func definitionIds() -> [String] - static func startObserving(with observer: VoltraDynamicLiveActivityObserver) async - static func reload(definitionIds: Set?) async + init(name: String, deepLinkUrl: String?) } #endif From 57a3a7bf4985865720a704dd229a39e4ac711cf5 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 10:03:10 +0200 Subject: [PATCH 13/24] fix(ios): safely deliver render failures --- ...cLiveActivityRenderFailureQueueTests.swift | 57 +++++++++- packages/ios-client/ios/app/NativeVoltra.mm | 5 + .../ios-client/ios/app/VoltraModule.swift | 4 + .../ios-client/ios/app/VoltraModuleImpl.swift | 4 + .../ios/shared/VoltraEventBus.swift | 13 ++- ...ynamicLiveActivityRenderFailureQueue.swift | 105 ++++++++++++++---- ...micLiveActivityRenderFailureReporter.swift | 44 +++----- packages/ios-client/src/events.ts | 4 +- .../ios-client/src/native/NativeVoltra.ts | 1 + .../renderFailureEvents.node.test.ts | 10 +- 10 files changed, 187 insertions(+), 60 deletions(-) diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityRenderFailureQueueTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityRenderFailureQueueTests.swift index 1ddde57a..13b96396 100644 --- a/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityRenderFailureQueueTests.swift +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityRenderFailureQueueTests.swift @@ -54,6 +54,45 @@ final class DynamicLiveActivityRenderFailureQueueTests: XCTestCase { XCTAssertEqual(storage.interactionEvents, ["interaction event"]) } + func testFileStorageRecoversFromCorruptData() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("not-json".utf8).write( + to: directory.appendingPathComponent("dynamic-live-activity-render-failures-v1.json") + ) + let queue = VoltraDynamicLiveActivityRenderFailureQueue( + storage: VoltraDynamicLiveActivityRenderFailureFileStorage(directoryURL: directory) + ) + + XCTAssertTrue(queue.record(failure(1))) + XCTAssertEqual(queue.drain(), [failure(1)]) + XCTAssertTrue( + try FileManager.default.contentsOfDirectory(atPath: directory.path).contains { $0.contains(".corrupt-") } + ) + } + + func testFileStorageSerializesConcurrentWritersAcrossQueueInstances() { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let queues = (0 ..< 20).map { _ in + VoltraDynamicLiveActivityRenderFailureQueue( + storage: VoltraDynamicLiveActivityRenderFailureFileStorage(directoryURL: directory) + ) + } + let group = DispatchGroup() + for (index, queue) in queues.enumerated() { + group.enter() + DispatchQueue.global().async { + XCTAssertTrue(queue.record(self.failure(index))) + group.leave() + } + } + XCTAssertEqual(group.wait(timeout: .now() + 5), .success) + + let failures = queues[0].drain() + XCTAssertEqual(failures.count, 20) + XCTAssertEqual(Set(failures.map(\.activityName)).count, 20) + } + private func failure(_ index: Int) -> VoltraDynamicLiveActivityRenderFailure { VoltraDynamicLiveActivityRenderFailure( activityName: "activity-\(index)", @@ -68,11 +107,21 @@ private final class InMemoryRenderFailureStorage: VoltraDynamicLiveActivityRende var failures: [VoltraDynamicLiveActivityRenderFailure] = [] var interactionEvents: [String] = [] - func load() throws -> [VoltraDynamicLiveActivityRenderFailure] { - failures + private let lock = NSLock() + + func append(_ failure: VoltraDynamicLiveActivityRenderFailure, capacity: Int) throws { + lock.lock() + defer { lock.unlock() } + failures.append(failure) + if failures.count > capacity { + failures.removeFirst(failures.count - capacity) + } } - func save(_ failures: [VoltraDynamicLiveActivityRenderFailure]) throws { - self.failures = failures + func drain() throws -> [VoltraDynamicLiveActivityRenderFailure] { + lock.lock() + defer { lock.unlock() } + defer { failures.removeAll() } + return failures } } diff --git a/packages/ios-client/ios/app/NativeVoltra.mm b/packages/ios-client/ios/app/NativeVoltra.mm index 5167f603..396d723f 100644 --- a/packages/ios-client/ios/app/NativeVoltra.mm +++ b/packages/ios-client/ios/app/NativeVoltra.mm @@ -92,6 +92,11 @@ - (void)applicationWillEnterForeground [self updateRootAppPropertiesHeadless:NO]; } +- (void)drainDynamicLiveActivityRenderFailures +{ + [self.module requestDynamicLiveActivityRenderFailureDrain]; +} + - (UIView *)reactRootViewInView:(UIView *)view { if ([view respondsToSelector:NSSelectorFromString(@"appProperties")] && diff --git a/packages/ios-client/ios/app/VoltraModule.swift b/packages/ios-client/ios/app/VoltraModule.swift index d1bb02ef..ef954cc3 100644 --- a/packages/ios-client/ios/app/VoltraModule.swift +++ b/packages/ios-client/ios/app/VoltraModule.swift @@ -145,6 +145,10 @@ public enum VoltraErrors: Error { impl.drainDynamicLiveActivityRenderFailures() } + @objc public func requestDynamicLiveActivityRenderFailureDrain() { + impl.requestDynamicLiveActivityRenderFailureDrain() + } + // MARK: - Images @objc public func preloadImages( diff --git a/packages/ios-client/ios/app/VoltraModuleImpl.swift b/packages/ios-client/ios/app/VoltraModuleImpl.swift index ff519d43..1a45150e 100644 --- a/packages/ios-client/ios/app/VoltraModuleImpl.swift +++ b/packages/ios-client/ios/app/VoltraModuleImpl.swift @@ -93,6 +93,10 @@ public class VoltraModuleImpl { VoltraEventBus.shared.drainDynamicLiveActivityRenderFailures() } + func requestDynamicLiveActivityRenderFailureDrain() { + VoltraEventBus.shared.requestDynamicLiveActivityRenderFailureDrain() + } + var pushNotificationsEnabled: Bool { // Support both keys for compatibility with older plugin and new Voltra naming let main = Bundle.main diff --git a/packages/ios-client/ios/shared/VoltraEventBus.swift b/packages/ios-client/ios/shared/VoltraEventBus.swift index aaf7256b..f1011973 100644 --- a/packages/ios-client/ios/shared/VoltraEventBus.swift +++ b/packages/ios-client/ios/shared/VoltraEventBus.swift @@ -10,6 +10,7 @@ public class VoltraEventBus { private var observer: NSObjectProtocol? private var renderFailureObserver: UUID? private var handler: ((String, [String: Any]) -> Void)? + private var isRenderFailureListenerReady = false private let lock = NSLock() private init() {} @@ -75,6 +76,14 @@ public class VoltraEventBus { handler(event.name, event.data) } VoltraLogger.event.info("Replayed \(persistedEvents.count) persisted events") + } + + /// Called only after JavaScript has installed the dedicated failure listener. + /// Until then notifier and foreground callbacks leave persisted failures intact. + public func requestDynamicLiveActivityRenderFailureDrain() { + lock.lock() + isRenderFailureListenerReady = true + lock.unlock() drainDynamicLiveActivityRenderFailures() } @@ -83,8 +92,9 @@ public class VoltraEventBus { public func drainDynamicLiveActivityRenderFailures() { lock.lock() let handler = handler + let isReady = isRenderFailureListenerReady lock.unlock() - guard let handler else { return } + guard isReady, let handler else { return } let failures = VoltraDynamicLiveActivityRenderFailureReporter.drain() for failure in failures { @@ -109,6 +119,7 @@ public class VoltraEventBus { self.renderFailureObserver = nil } handler = nil + isRenderFailureListenerReady = false } deinit { diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift index 270ff6cd..9fee8509 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift @@ -1,5 +1,11 @@ import Foundation +#if canImport(Darwin) + import Darwin +#else + import Glibc +#endif + /// The intentionally minimal diagnostic record sent from the widget extension /// to the app after a Dynamic Live Activity definition cannot be rendered. /// Do not add props, tokens, stages, or arbitrary error details here. @@ -47,35 +53,95 @@ public struct VoltraDynamicLiveActivityRenderFailure: Codable, Equatable { } public protocol VoltraDynamicLiveActivityRenderFailureStorage { - func load() throws -> [VoltraDynamicLiveActivityRenderFailure] - func save(_ failures: [VoltraDynamicLiveActivityRenderFailure]) throws + func append(_ failure: VoltraDynamicLiveActivityRenderFailure, capacity: Int) throws + func drain() throws -> [VoltraDynamicLiveActivityRenderFailure] +} + +/// Cross-process-safe file storage for the dedicated failure queue. +/// +/// The app and widget extension take an advisory lock on the same App Group +/// lock file before every read/modify/write transaction. Atomic replacement of +/// the JSON data file prevents partial writes. Corrupt data is quarantined and +/// treated as an empty queue so one damaged record cannot permanently disable +/// diagnostics. +public final class VoltraDynamicLiveActivityRenderFailureFileStorage: VoltraDynamicLiveActivityRenderFailureStorage { + private let directoryURL: URL + private let fileManager: FileManager + private let queueFileName = "dynamic-live-activity-render-failures-v1.json" + private let lockFileName = "dynamic-live-activity-render-failures-v1.lock" + + public init(directoryURL: URL, fileManager: FileManager = .default) { + self.directoryURL = directoryURL + self.fileManager = fileManager + } + + public func append(_ failure: VoltraDynamicLiveActivityRenderFailure, capacity: Int) throws { + try withExclusiveAccess { queueURL in + var failures = loadRecoveringCorruption(from: queueURL) + failures.append(failure) + if failures.count > capacity { + failures.removeFirst(failures.count - capacity) + } + try write(failures, to: queueURL) + } + } + + public func drain() throws -> [VoltraDynamicLiveActivityRenderFailure] { + try withExclusiveAccess { queueURL in + let failures = loadRecoveringCorruption(from: queueURL) + try write([], to: queueURL) + return failures + } + } + + private func withExclusiveAccess( + _ operation: (URL) throws -> Result + ) throws -> Result { + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let lockURL = directoryURL.appendingPathComponent(lockFileName, isDirectory: false) + let descriptor = open(lockURL.path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) + guard descriptor >= 0 else { throw POSIXError(.EIO) } + defer { close(descriptor) } + guard flock(descriptor, LOCK_EX) == 0 else { throw POSIXError(.EIO) } + defer { flock(descriptor, LOCK_UN) } + return try operation(directoryURL.appendingPathComponent(queueFileName, isDirectory: false)) + } + + private func loadRecoveringCorruption(from url: URL) -> [VoltraDynamicLiveActivityRenderFailure] { + guard fileManager.fileExists(atPath: url.path) else { return [] } + do { + return try JSONDecoder().decode( + [VoltraDynamicLiveActivityRenderFailure].self, + from: Data(contentsOf: url) + ) + } catch { + let quarantineURL = directoryURL.appendingPathComponent( + "\(queueFileName).corrupt-\(UUID().uuidString)", + isDirectory: false + ) + try? fileManager.moveItem(at: url, to: quarantineURL) + return [] + } + } + + private func write(_ failures: [VoltraDynamicLiveActivityRenderFailure], to url: URL) throws { + try JSONEncoder().encode(failures).write(to: url, options: .atomic) + } } -/// A dedicated, bounded queue. Its lock makes draining and appending atomic in -/// a process: a failure recorded while a drain is in progress remains queued -/// for the next drain rather than being cleared accidentally. +/// A dedicated, bounded queue whose storage owns the append/drain transaction. public final class VoltraDynamicLiveActivityRenderFailureQueue { public static let capacity = 100 private let storage: VoltraDynamicLiveActivityRenderFailureStorage - private let lock = NSLock() - public init(storage: VoltraDynamicLiveActivityRenderFailureStorage) { self.storage = storage } @discardableResult public func record(_ failure: VoltraDynamicLiveActivityRenderFailure) -> Bool { - lock.lock() - defer { lock.unlock() } - do { - var failures = try storage.load() - failures.append(failure) - if failures.count > Self.capacity { - failures.removeFirst(failures.count - Self.capacity) - } - try storage.save(failures) + try storage.append(failure, capacity: Self.capacity) return true } catch { return false @@ -83,13 +149,8 @@ public final class VoltraDynamicLiveActivityRenderFailureQueue { } public func drain() -> [VoltraDynamicLiveActivityRenderFailure] { - lock.lock() - defer { lock.unlock() } - do { - let failures = try storage.load() - try storage.save([]) - return failures + return try storage.drain() } catch { return [] } diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift index f6bbe3a7..f0cbb299 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift @@ -1,32 +1,6 @@ import CoreFoundation import Foundation -private final class VoltraDynamicLiveActivityRenderFailureUserDefaultsStorage: VoltraDynamicLiveActivityRenderFailureStorage { - private static let key = "Voltra_DynamicLiveActivity_RenderFailures_v1" - - func load() throws -> [VoltraDynamicLiveActivityRenderFailure] { - guard let defaults = defaults() else { throw StorageError.unavailable } - guard let data = defaults.data(forKey: Self.key) else { return [] } - return try JSONDecoder().decode([VoltraDynamicLiveActivityRenderFailure].self, from: data) - } - - func save(_ failures: [VoltraDynamicLiveActivityRenderFailure]) throws { - guard let defaults = defaults() else { throw StorageError.unavailable } - try defaults.set(JSONEncoder().encode(failures), forKey: Self.key) - guard defaults.synchronize() else { throw StorageError.writeFailed } - } - - private func defaults() -> UserDefaults? { - guard let group = VoltraConfig.groupIdentifier() else { return nil } - return UserDefaults(suiteName: group) - } - - private enum StorageError: Error { - case unavailable - case writeFailed - } -} - /// Cross-process notification relay for the App Group backed failure queue. /// Darwin notifications carry no payload, so consumers always drain storage. private final class VoltraDynamicLiveActivityRenderFailureNotifier { @@ -100,9 +74,17 @@ private final class VoltraDynamicLiveActivityRenderFailureNotifier { /// Owns the production App Group queue and OS logging path. The extension only /// records failures; the app's event bus drains them when JavaScript can listen. public enum VoltraDynamicLiveActivityRenderFailureReporter { - private static let queue = VoltraDynamicLiveActivityRenderFailureQueue( - storage: VoltraDynamicLiveActivityRenderFailureUserDefaultsStorage() - ) + private static var queue: VoltraDynamicLiveActivityRenderFailureQueue? { + guard + let group = VoltraConfig.groupIdentifier(), + let directoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) + else { + return nil + } + return VoltraDynamicLiveActivityRenderFailureQueue( + storage: VoltraDynamicLiveActivityRenderFailureFileStorage(directoryURL: directoryURL) + ) + } @discardableResult public static func record(activityName: String, definitionId: String, message: String) -> Bool { @@ -111,7 +93,7 @@ public enum VoltraDynamicLiveActivityRenderFailureReporter { definitionId: definitionId, message: message ) - let persisted = queue.record(failure) + let persisted = queue?.record(failure) ?? false VoltraLogger.activity.error( "[DynamicLiveActivity] activity=\(failure.activityName, privacy: .public) definitionId=\(failure.definitionId, privacy: .public) \(failure.message, privacy: .public)" ) @@ -124,7 +106,7 @@ public enum VoltraDynamicLiveActivityRenderFailureReporter { } public static func drain() -> [VoltraDynamicLiveActivityRenderFailure] { - queue.drain() + queue?.drain() ?? [] } /// The returned token must be removed when the JS bridge listener goes away. diff --git a/packages/ios-client/src/events.ts b/packages/ios-client/src/events.ts index f77d2a4b..49523336 100644 --- a/packages/ios-client/src/events.ts +++ b/packages/ios-client/src/events.ts @@ -72,9 +72,11 @@ export function addVoltraListener( case 'interaction': return voltraModule.onInteraction(listener as (arg: VoltraInteractionEvent) => void) case 'dynamicLiveActivityRenderFailed': - return voltraModule.onDynamicLiveActivityRenderFailed( + const subscription = voltraModule.onDynamicLiveActivityRenderFailed( listener as (arg: VoltraDynamicLiveActivityRenderFailedEvent) => void ) + voltraModule.drainDynamicLiveActivityRenderFailures() + return subscription default: console.warn(`[Voltra] Event '${event}' is not supported. Returning no-op subscription.`) return noopSubscription diff --git a/packages/ios-client/src/native/NativeVoltra.ts b/packages/ios-client/src/native/NativeVoltra.ts index e827956e..9f8aaaf8 100644 --- a/packages/ios-client/src/native/NativeVoltra.ts +++ b/packages/ios-client/src/native/NativeVoltra.ts @@ -99,6 +99,7 @@ export interface Spec extends TurboModule { readonly onStateChanged: CodegenTypes.EventEmitter readonly onActivityTokenReceived: CodegenTypes.EventEmitter readonly onActivityPushToStartTokenReceived: CodegenTypes.EventEmitter + drainDynamicLiveActivityRenderFailures(): void startLiveActivity(jsonString: string, options: StartVoltraOptions): Promise updateLiveActivity(activityId: string, jsonString: string, options: UpdateVoltraOptions): Promise startDynamicLiveActivity(definitionId: string, propsJson: string, options: StartVoltraOptions): Promise diff --git a/packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts b/packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts index f262c77c..6a11ef9a 100644 --- a/packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts +++ b/packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts @@ -12,12 +12,20 @@ describe('Dynamic Live Activity render failure events', () => { it('subscribes through the dedicated native emitter with the exported event shape', () => { const subscription = { remove: jest.fn() } const onDynamicLiveActivityRenderFailed = jest.fn(() => subscription) - mockedGetNativeVoltra.mockReturnValue({ onDynamicLiveActivityRenderFailed } as unknown as Spec) + const drainDynamicLiveActivityRenderFailures = jest.fn() + mockedGetNativeVoltra.mockReturnValue({ + onDynamicLiveActivityRenderFailed, + drainDynamicLiveActivityRenderFailures, + } as unknown as Spec) const listener = jest.fn<(event: VoltraDynamicLiveActivityRenderFailedEvent) => void>() const returned = addVoltraListener('dynamicLiveActivityRenderFailed', listener) expect(onDynamicLiveActivityRenderFailed).toHaveBeenCalledWith(listener) + expect(drainDynamicLiveActivityRenderFailures).toHaveBeenCalledTimes(1) + expect(onDynamicLiveActivityRenderFailed.mock.invocationCallOrder[0]).toBeLessThan( + drainDynamicLiveActivityRenderFailures.mock.invocationCallOrder[0]! + ) expect(returned).toBe(subscription) }) }) From ddd4a3d2273fcf42260f37dc31095db9e59a9e37 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 10:23:38 +0200 Subject: [PATCH 14/24] fix(ios): enforce exact ActivityKit payload budget --- ...micLiveActivityPayloadValidatorTests.swift | 59 +++++++++++++++++++ ...aDynamicLiveActivityPayloadValidator.swift | 42 +++++++------ 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityPayloadValidatorTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityPayloadValidatorTests.swift index a1a7748f..ec03e443 100644 --- a/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityPayloadValidatorTests.swift +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityPayloadValidatorTests.swift @@ -23,4 +23,63 @@ final class DynamicLiveActivityPayloadValidatorTests: XCTestCase { } } } + + func testUpdateAcceptsExactly4096EncodedBytesAndRejects4097() throws { + let exact = try propsWithEncodedContentStateSize(4096, character: "x") + let oversized = try propsWithEncodedContentStateSize(4097, character: "x") + + XCTAssertEqual(try VoltraDynamicLiveActivityPayloadValidator.encodedContentStateSize(exact), 4096) + XCTAssertNoThrow(try VoltraDynamicLiveActivityPayloadValidator.validateContentState(exact)) + XCTAssertThrowsError(try VoltraDynamicLiveActivityPayloadValidator.validateContentState(oversized)) + } + + func testCountsUnicodeByEncodedBytesRatherThanCharacters() throws { + let value = String(repeating: "🛵", count: 1020) + let props: [String: VoltraDynamicLiveActivityJSONValue] = ["value": .string(value)] + + XCTAssertLessThan(value.count, 4096) + XCTAssertGreaterThan(try VoltraDynamicLiveActivityPayloadValidator.encodedContentStateSize(props), 4096) + XCTAssertThrowsError(try VoltraDynamicLiveActivityPayloadValidator.validateContentState(props)) + } + + func testStartUsesCombinedAttributesAndContentStateBudget() throws { + let props = try propsWithEncodedContentStateSize(4000, character: "x") + let size = try VoltraDynamicLiveActivityPayloadValidator.encodedStartSize( + name: String(repeating: "n", count: 100), + deepLinkUrl: nil, + props: props + ) + + XCTAssertGreaterThan(size, 4096) + XCTAssertThrowsError( + try VoltraDynamicLiveActivityPayloadValidator.validate( + name: String(repeating: "n", count: 100), + deepLinkUrl: nil, + props: props + ) + ) + } + + func testUpdateDoesNotIncludeStaticAttributesInItsBudget() throws { + let props = try propsWithEncodedContentStateSize(4096, character: "x") + + XCTAssertNoThrow(try VoltraDynamicLiveActivityPayloadValidator.validateContentState(props)) + } + + private func propsWithEncodedContentStateSize( + _ target: Int, + character: Character + ) throws -> [String: VoltraDynamicLiveActivityJSONValue] { + var value = "" + while true { + let props: [String: VoltraDynamicLiveActivityJSONValue] = ["value": .string(value)] + let size = try VoltraDynamicLiveActivityPayloadValidator.encodedContentStateSize(props) + if size == target { return props } + if size > target { + XCTFail("Could not construct an encoded payload of exactly \(target) bytes") + return props + } + value.append(character) + } + } } diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift index 241e9eec..77f870b2 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift @@ -4,16 +4,6 @@ import Foundation /// This is intentionally uncompressed: ActivityKit's 4 KB limit applies to its /// encoded attributes and content state, not Voltra's legacy rendered payload. public enum VoltraDynamicLiveActivityPayloadValidator { - private struct Payload: Encodable { - let attributes: Attributes - let contentState: VoltraDynamicLiveActivityContentState - - enum CodingKeys: String, CodingKey { - case attributes - case contentState = "content-state" - } - } - private struct Attributes: Encodable { let name: String let deepLinkUrl: String? @@ -29,19 +19,35 @@ public enum VoltraDynamicLiveActivityPayloadValidator { deepLinkUrl: String?, props: [String: VoltraDynamicLiveActivityJSONValue] ) throws { - let payload = Payload( - attributes: Attributes(name: name, deepLinkUrl: deepLinkUrl), - contentState: VoltraDynamicLiveActivityContentState(props: props) - ) - try validateEncoded(payload) + let attributesSize = try encodedSize(Attributes(name: name, deepLinkUrl: deepLinkUrl)) + let contentStateSize = try encodedSize(VoltraDynamicLiveActivityContentState(props: props)) + try validateSize(attributesSize + contentStateSize) } public static func validateContentState(_ props: [String: VoltraDynamicLiveActivityJSONValue]) throws { - try validateEncoded(VoltraDynamicLiveActivityContentState(props: props)) + try validateSize(encodedSize(VoltraDynamicLiveActivityContentState(props: props))) + } + + public static func encodedContentStateSize( + _ props: [String: VoltraDynamicLiveActivityJSONValue] + ) throws -> Int { + try encodedSize(VoltraDynamicLiveActivityContentState(props: props)) + } + + public static func encodedStartSize( + name: String, + deepLinkUrl: String?, + props: [String: VoltraDynamicLiveActivityJSONValue] + ) throws -> Int { + try encodedSize(Attributes(name: name, deepLinkUrl: deepLinkUrl)) + + encodedSize(VoltraDynamicLiveActivityContentState(props: props)) + } + + private static func encodedSize(_ value: some Encodable) throws -> Int { + try JSONEncoder().encode(value).count } - private static func validateEncoded(_ value: some Encodable) throws { - let size = try JSONEncoder().encode(value).count + private static func validateSize(_ size: Int) throws { guard size <= VoltraConstants.maxPayloadSizeBytes else { throw VoltraDynamicLiveActivityError.payloadTooLarge(size: size) } From 43f2acfb5eec8c0a45b3d072b9d6ea3b2b59cb77 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 10:28:08 +0200 Subject: [PATCH 15/24] fix(ios): honor dynamic render environment --- packages/ios-client/expo-plugin/src/index.ts | 11 +++ .../expo-plugin/src/ios-widget/files/index.ts | 6 +- .../ios-widget/files/infoPlist.node.test.ts | 20 ++++++ .../src/ios-widget/files/infoPlist.ts | 19 ++++- .../expo-plugin/src/ios-widget/index.ts | 4 +- .../ios-client/expo-plugin/src/ios/index.ts | 2 + .../expo-plugin/src/ios/infoPlist.ts | 4 +- .../ios/shared/VoltraConstants.swift | 1 + .../VoltraDynamicLiveActivityRenderer.swift | 72 ++++++++++++++++--- .../ui/Helpers/VoltraDeepLinkResolver.swift | 4 +- 10 files changed, 123 insertions(+), 20 deletions(-) create mode 100644 packages/ios-client/expo-plugin/src/ios-widget/files/infoPlist.node.test.ts diff --git a/packages/ios-client/expo-plugin/src/index.ts b/packages/ios-client/expo-plugin/src/index.ts index 131bd17e..cbc4ea9b 100644 --- a/packages/ios-client/expo-plugin/src/index.ts +++ b/packages/ios-client/expo-plugin/src/index.ts @@ -1,4 +1,6 @@ import { IOSConfig } from 'expo/config-plugins' +import { createRequire } from 'node:module' +import path from 'node:path' import { IOS } from './constants' import { withIOS, withPushNotifications } from './ios' @@ -16,6 +18,13 @@ const withVoltraIos: VoltraIosConfigPlugin = (config, props = {}) => { const projectRoot = (config as { modRequest?: { projectRoot?: string } }).modRequest?.projectRoot validateIOSConfigPluginProps(props, projectRoot) + const requireFromProject = createRequire(path.join(projectRoot ?? process.cwd(), 'package.json')) + const iosClientPackage = requireFromProject('@use-voltra/ios-client/package.json') as { version?: unknown } + if (typeof iosClientPackage.version !== 'string') { + throw new Error('Could not determine the installed @use-voltra/ios-client version') + } + const voltraVersion = iosClientPackage.version + const iosBundleIdentifier = config.ios?.bundleIdentifier if (!iosBundleIdentifier) { throw new Error( @@ -43,6 +52,7 @@ const withVoltraIos: VoltraIosConfigPlugin = (config, props = {}) => { widgets: props.widgets, liveActivities: props.liveActivities, keychainGroup, + voltraVersion, }) config = withIOSWidget(config, { @@ -53,6 +63,7 @@ const withVoltraIos: VoltraIosConfigPlugin = (config, props = {}) => { liveActivities: props.liveActivities, version, buildNumber, + voltraVersion, ...(props.groupIdentifier ? { groupIdentifier: props.groupIdentifier } : {}), ...(keychainGroup ? { keychainGroup } : {}), ...(props.fonts ? { fonts: props.fonts } : {}), 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 bd2c36c7..dd01b1af 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 @@ -17,6 +17,7 @@ export interface GenerateWidgetExtensionFilesProps { keychainGroup?: string version: string buildNumber: string + voltraVersion: string } /** @@ -32,7 +33,8 @@ export interface GenerateWidgetExtensionFilesProps { * This should run before configureXcodeProject so the files exist when Xcode project is configured. */ export const generateWidgetExtensionFiles: ConfigPlugin = (config, props) => { - const { targetName, widgets, liveActivities, groupIdentifier, keychainGroup, version, buildNumber } = props + const { targetName, widgets, liveActivities, groupIdentifier, keychainGroup, version, buildNumber, voltraVersion } = + props return withDangerousMod(config, [ 'ios', @@ -50,7 +52,7 @@ export const generateWidgetExtensionFiles: ConfigPlugin { + it('records the installed Voltra version for the renderer environment', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-plist-')) + try { + generateInfoPlist(directory, 'ExampleLiveActivity', '1.0.0', '42', '2.2.0') + + const plist = fs.readFileSync(path.join(directory, 'Info.plist'), 'utf8') + expect(plist).toContain('Voltra_Version') + expect(plist).toContain('2.2.0') + } finally { + fs.rmSync(directory, { 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..06741a3d 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 @@ -50,8 +57,14 @@ function generateInfoPlistContent(targetName: string, version: string, buildNumb * @param version - The app version (CFBundleShortVersionString) * @param buildNumber - The build number (CFBundleVersion) */ -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/expo-plugin/src/ios-widget/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/index.ts index 06ffe3d2..db996b69 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/index.ts @@ -19,6 +19,7 @@ export interface WithIOSProps { fonts?: string[] version: string buildNumber: string + voltraVersion: string } /** @@ -49,6 +50,7 @@ export const withIOS: ConfigPlugin = (config, props) => { fonts, version, buildNumber, + voltraVersion, } = props const plugins: [ConfigPlugin, any][] = [ @@ -73,7 +75,7 @@ export const withIOS: ConfigPlugin = (config, props) => { // 6. Generate widget extension files (dangerous mod should run before plist patchers) [ generateWidgetExtensionFiles, - { targetName, widgets, liveActivities, groupIdentifier, keychainGroup, version, buildNumber }, + { targetName, widgets, liveActivities, groupIdentifier, keychainGroup, version, buildNumber, voltraVersion }, ], ] diff --git a/packages/ios-client/expo-plugin/src/ios/index.ts b/packages/ios-client/expo-plugin/src/ios/index.ts index a56d2119..33547644 100644 --- a/packages/ios-client/expo-plugin/src/ios/index.ts +++ b/packages/ios-client/expo-plugin/src/ios/index.ts @@ -10,6 +10,7 @@ export interface IOSConfigProps { widgets?: import('../types').IOSWidgetConfig[] liveActivities?: import('../types').IOSDynamicLiveActivityConfig[] keychainGroup?: string + voltraVersion: string } /** @@ -28,6 +29,7 @@ export function withIOS(config: ExpoConfig, props: IOSConfigProps): ExpoConfig { widgetIds: props.widgetIds, widgets: props.widgets, keychainGroup: props.keychainGroup, + voltraVersion: props.voltraVersion, }) // Configure entitlements diff --git a/packages/ios-client/expo-plugin/src/ios/infoPlist.ts b/packages/ios-client/expo-plugin/src/ios/infoPlist.ts index 8a3c1173..8559cda7 100644 --- a/packages/ios-client/expo-plugin/src/ios/infoPlist.ts +++ b/packages/ios-client/expo-plugin/src/ios/infoPlist.ts @@ -7,6 +7,7 @@ export interface ConfigureInfoPlistProps { widgetIds?: string[] widgets?: IOSWidgetConfig[] keychainGroup?: string + voltraVersion: string } /** @@ -20,10 +21,11 @@ export interface ConfigureInfoPlistProps { * - Voltra_WidgetServerIntervals: Map of widget IDs to update intervals (if any widgets have serverUpdate) * - Voltra_KeychainGroup: Keychain access group for shared credentials (if provided) */ -export const configureInfoPlist: ConfigPlugin = (config, props = {}) => { +export const configureInfoPlist: ConfigPlugin = (config, props) => { return withInfoPlist(config, (mod) => { mod.modResults.NSSupportsLiveActivities = true mod.modResults.NSSupportsLiveActivitiesFrequentUpdates = false + mod.modResults.Voltra_Version = props.voltraVersion // Only add group identifier if provided if (props.groupIdentifier) { 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/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift index 749c20cb..e1436ed6 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift @@ -21,29 +21,57 @@ public enum VoltraDynamicLiveActivityRenderer { definitionId: String, context: ActivityViewContext ) -> DynamicIsland { - let content = resolve(definitionId: definitionId, context: context, activityFamily: nil) + let fallbackContent = resolve(definitionId: definitionId, context: context, activityFamily: nil) let island = DynamicIsland { DynamicIslandExpandedRegion(.leading) { - content.view(for: .islandExpandedLeading, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandExpandedLeading + ) } DynamicIslandExpandedRegion(.trailing) { - content.view(for: .islandExpandedTrailing, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandExpandedTrailing + ) } DynamicIslandExpandedRegion(.center) { - content.view(for: .islandExpandedCenter, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandExpandedCenter + ) } DynamicIslandExpandedRegion(.bottom) { - content.view(for: .islandExpandedBottom, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandExpandedBottom + ) } } compactLeading: { - content.view(for: .islandCompactLeading, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandCompactLeading + ) } compactTrailing: { - content.view(for: .islandCompactTrailing, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandCompactTrailing + ) } minimal: { - content.view(for: .islandMinimal, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandMinimal + ) } - if let keylineTint = content.payload?.keylineTint, let color = JSColorParser.parse(keylineTint) { + if let keylineTint = fallbackContent.payload?.keylineTint, let color = JSColorParser.parse(keylineTint) { return island.keylineTint(color) } return island @@ -105,6 +133,28 @@ public enum VoltraDynamicLiveActivityRenderer { } } +private struct VoltraDynamicLiveActivityDynamicIslandRegionView: View { + let definitionId: String + let context: ActivityViewContext + let region: VoltraRegion + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.locale) private var locale + @Environment(\.widgetRenderingMode) private var widgetRenderingMode + + var body: some View { + VoltraDynamicLiveActivityRenderer.resolve( + definitionId: definitionId, + context: context, + activityFamily: nil, + colorScheme: colorScheme, + locale: locale, + widgetRenderingMode: widgetRenderingMode + ) + .view(for: region, activityId: context.activityID, deepLink: context.attributes.deepLinkUrl) + } +} + private struct VoltraDynamicLiveActivityLockScreenView: View { let definitionId: String let context: ActivityViewContext @@ -200,7 +250,7 @@ private struct VoltraDynamicLiveActivityResolvedContent { if let nodes = payload?.regions[region], !nodes.isEmpty { let root: VoltraNode = nodes.count == 1 ? nodes[0] : .array(nodes) Voltra(root: root, activityId: activityId) - .voltraIfLet(deepLink) { view, url in view.widgetURL(URL(string: url)) } + .voltraIfLet(deepLink.flatMap(VoltraDeepLinkResolver.resolveUrl)) { view, url in view.widgetURL(url) } } } } @@ -225,7 +275,7 @@ private enum VoltraDynamicLiveActivityEnvironmentBuilder { "isDev": isDev, "metroUrl": metroURL as Any, "appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown", - "voltraVersion": "1.4.1", + "voltraVersion": Bundle.main.object(forInfoDictionaryKey: VoltraStorageKeys.voltraVersion) as? String ?? "unknown", ] var environment: [String: Any] = [ "date": Int(date.timeIntervalSince1970 * 1000), diff --git a/packages/ios-client/ios/ui/Helpers/VoltraDeepLinkResolver.swift b/packages/ios-client/ios/ui/Helpers/VoltraDeepLinkResolver.swift index c3ee0723..33292616 100644 --- a/packages/ios-client/ios/ui/Helpers/VoltraDeepLinkResolver.swift +++ b/packages/ios-client/ios/ui/Helpers/VoltraDeepLinkResolver.swift @@ -2,7 +2,7 @@ import ActivityKit import Foundation import SwiftUI -enum VoltraDeepLinkResolver { +public enum VoltraDeepLinkResolver { static func deepLinkScheme() -> String? { if let types = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]] { for t in types { @@ -26,7 +26,7 @@ enum VoltraDeepLinkResolver { /// Resolves a URL string, supporting both absolute and relative paths /// - Parameter raw: The URL string (e.g., "myapp://path", "/path", or "path") /// - Returns: A resolved URL, or nil if invalid - static func resolveUrl(_ raw: String) -> URL? { + public static func resolveUrl(_ raw: String) -> URL? { guard !raw.isEmpty else { return nil } // If it's already an absolute URL, use it as-is From c01fb03b5eca92c4ca409be5ed370d2b71677206 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 10:28:25 +0200 Subject: [PATCH 16/24] fix(ios): refresh dynamic definitions safely --- example/app/_layout.tsx | 6 +- packages/ios-client/Voltra.podspec | 1 + .../src/ios-widget/files/swift.node.test.ts | 5 +- .../expo-plugin/src/ios-widget/files/swift.ts | 12 +-- .../xcode/applyXcodeChanges.node.test.ts | 13 +-- .../ios-widget/xcode/buildPhases.node.test.ts | 2 +- .../src/ios-widget/xcode/buildPhases.ts | 2 +- .../expo-plugin/src/ios-widget/xcode/index.ts | 5 ++ packages/ios-client/ios/VoltraWidget.podspec | 1 + packages/ios-client/ios/app/NativeVoltra.mm | 6 +- .../ios/app/VoltraLiveActivityService.swift | 4 +- .../ios/app/VoltraViewComponentView.mm | 6 +- .../ios/shared/VoltraJSRenderer.swift | 26 ++++-- ...oltraDynamicLiveActivityBundleSource.swift | 82 +++++++++++++++++-- 14 files changed, 132 insertions(+), 39 deletions(-) diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index 20b3dee1..b242516c 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -1,7 +1,10 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { SafeAreaProvider } from 'react-native-safe-area-context' -import { enableWidgetHotReload as enableIosWidgetHotReload } from '@use-voltra/ios-client' +import { + enableDynamicLiveActivityHotReload, + enableWidgetHotReload as enableIosWidgetHotReload, +} from '@use-voltra/ios-client' import { enableWidgetHotReload as enableAndroidWidgetHotReload } from '@use-voltra/android-client' import '@use-voltra/widget-hot-reload' @@ -13,6 +16,7 @@ if (Platform.OS === 'android') { enableAndroidWidgetHotReload() } else { enableIosWidgetHotReload() + enableDynamicLiveActivityHotReload() } updateAndroidVoltraWidget({ width: 300, height: 200 }) diff --git a/packages/ios-client/Voltra.podspec b/packages/ios-client/Voltra.podspec index d0aa60c3..f2958ceb 100644 --- a/packages/ios-client/Voltra.podspec +++ b/packages/ios-client/Voltra.podspec @@ -14,6 +14,7 @@ Pod::Spec.new do |s| :ios => '16.4', } s.swift_version = '5.9' + s.module_name = 'VoltraRuntime' s.source = { git: 'https://github.com/callstackincubator/voltra' } s.static_framework = true install_modules_dependencies(s) diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts index c53ab3d0..025e6cb4 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts @@ -96,7 +96,8 @@ describe('Dynamic Live Activity Swift generation', () => { expect(types).toContain('public let name: String') expect(types).toContain('public let deepLinkUrl: String?') expect(types).toContain('public static let attributesTypeName = "VoltraOrderFinishedLiveActivityAttributes"') - expect(types).toContain('import VoltraWidget') + expect(types).toContain('import VoltraRuntime') + expect(types).not.toContain('import VoltraWidget') expect(types).toContain('@objc(VoltraGeneratedDynamicLiveActivityRegistration)') expect(types).toContain('public final class VoltraGeneratedDynamicLiveActivityRegistration: NSObject') expect(types).toContain( @@ -114,7 +115,7 @@ describe('Dynamic Live Activity Swift generation', () => { }) it('keeps the legacy empty bundle unchanged when no Dynamic Live Activities are declared', () => { - expect(__test__.generateWidgetBundleSwift([], [])).toContain('import VoltraWidget') + expect(__test__.generateWidgetBundleSwift([], [])).toContain('import VoltraRuntime') expect(__test__.generateWidgetBundleSwift([], [])).not.toContain('VoltraDynamicLiveActivity_') }) }) 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 f6f0053a..de7812eb 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 @@ -498,7 +498,7 @@ function generateWidgetBundleSwift( ${foundationImport}${appIntentsImport}import SwiftUI import WidgetKit - import VoltraWidget + import VoltraRuntime @main struct VoltraWidgetBundle: WidgetBundle { @@ -532,7 +532,7 @@ function generateDefaultWidgetBundleSwift(): string { import SwiftUI import WidgetKit - import VoltraWidget // Import Voltra widgets + import VoltraRuntime // Import Voltra widgets @main struct VoltraWidgetBundle: WidgetBundle { @@ -559,11 +559,7 @@ function generateDynamicLiveActivityTypesSwift(liveActivities: IOSDynamicLiveAct import ActivityKit import Foundation - #if canImport(VoltraWidget) - import VoltraWidget - #else - import Voltra - #endif + import VoltraRuntime @objc(VoltraGeneratedDynamicLiveActivityRegistration) public final class VoltraGeneratedDynamicLiveActivityRegistration: NSObject { @@ -660,7 +656,7 @@ function generateDynamicLiveActivitiesSwift(liveActivities: IOSDynamicLiveActivi import ActivityKit import SwiftUI import WidgetKit - import VoltraWidget + import VoltraRuntime ` return [header.trim(), configurations.trim()].filter(Boolean).join('\n\n') diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts index 2b830f09..40bc6eb0 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts @@ -50,12 +50,15 @@ describe('applyXcodeChanges — fresh Expo project (fixture a)', () => { const objects = project.hash.project.objects const widgetTarget = project.findTargetKey(PROPS.targetName) + const appTarget = project.getFirstTarget().uuid const shellPhases = objects.PBXShellScriptBuildPhase || {} - expect( - objects.PBXNativeTarget[widgetTarget].buildPhases.some((entry: any) => - String(shellPhases[String(entry.value).split(' ')[0]]?.name).includes('Bundle Voltra Dynamic Widgets') - ) - ).toBe(true) + for (const target of [widgetTarget, appTarget]) { + expect( + objects.PBXNativeTarget[target].buildPhases.some((entry: any) => + String(shellPhases[String(entry.value).split(' ')[0]]?.name).includes('Bundle Voltra Dynamic Content') + ) + ).toBe(true) + } const typeRefs = Object.entries(objects.PBXFileReference).filter( ([key, reference]: [string, any]) => diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.node.test.ts index 6372b1a8..9f68bd53 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.node.test.ts @@ -38,7 +38,7 @@ describe('ensureWidgetBundleScriptPhase', () => { expect(phases).toHaveLength(1) const phase = phases[0] - expect(phase.name).toContain('Bundle Voltra Dynamic Widgets') + expect(phase.name).toContain('Bundle Voltra Dynamic Content') expect(phase.shellScript).toContain('@use-voltra/metro/bundle-widgets') // Debug builds use Metro, so the script must skip them... expect(phase.shellScript).toContain('Debug') 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 df57565d..3014338c 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 @@ -6,7 +6,7 @@ import { ensureWidgetFileReference, normalizeRef } from './fileReferences' const pbxFile = require('xcode/lib/pbxFile') -const WIDGET_BUNDLE_PHASE_NAME = 'Bundle Voltra Dynamic Widgets' +const WIDGET_BUNDLE_PHASE_NAME = 'Bundle Voltra Dynamic Content' // Release-only build phase that bakes each Dynamic Widget's production JS bundle into the // extension's resources. Debug builds fetch from Metro (and hot-reload), so this no-ops there. Runs diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts index dc2c4ff0..7fdae127 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts @@ -43,6 +43,7 @@ export function applyXcodeChanges( ): void { const { targetName, bundleIdentifier, deploymentTarget, version, buildNumber } = props const groupName = 'Embed Foundation Extensions' + const mainTargetUuid = xcodeProject.getFirstTarget().uuid // The catalog is compiled into the app even for a legacy-only configuration. // Keep it in the extension group too so both targets share one PBX file reference. const effectiveWidgetFiles: IOSWidgetExtensionFiles = { @@ -117,6 +118,10 @@ export function applyXcodeChanges( if (hasClientRenderedWidgets || (props.liveActivities?.length ?? 0) > 0) { ensureWidgetBundleScriptPhase(xcodeProject, targetUuid) + // Local Dynamic Live Activity starts preflight the baked definition from + // the app process, while WidgetKit renders from the extension process. + // Bake the same manifest into both products. + ensureWidgetBundleScriptPhase(xcodeProject, mainTargetUuid) } ensureTargetAttributes(xcodeProject, targetUuid) diff --git a/packages/ios-client/ios/VoltraWidget.podspec b/packages/ios-client/ios/VoltraWidget.podspec index 3ecdfa0d..fd95f689 100644 --- a/packages/ios-client/ios/VoltraWidget.podspec +++ b/packages/ios-client/ios/VoltraWidget.podspec @@ -14,6 +14,7 @@ Pod::Spec.new do |s| :ios => '16.4', } s.swift_version = '5.9' + s.module_name = 'VoltraRuntime' s.source = { git: 'https://github.com/callstackincubator/voltra' } s.static_framework = true diff --git a/packages/ios-client/ios/app/NativeVoltra.mm b/packages/ios-client/ios/app/NativeVoltra.mm index 396d723f..c0bcc8e7 100644 --- a/packages/ios-client/ios/app/NativeVoltra.mm +++ b/packages/ios-client/ios/app/NativeVoltra.mm @@ -1,10 +1,10 @@ #import "NativeVoltra.h" #import -#if __has_include("Voltra/Voltra-Swift.h") -#import "Voltra/Voltra-Swift.h" +#if __has_include("Voltra/VoltraRuntime-Swift.h") +#import "Voltra/VoltraRuntime-Swift.h" #else -#import "Voltra-Swift.h" +#import "VoltraRuntime-Swift.h" #endif @interface VoltraLaunchObserver : NSObject diff --git a/packages/ios-client/ios/app/VoltraLiveActivityService.swift b/packages/ios-client/ios/app/VoltraLiveActivityService.swift index 2984aa92..ee33f264 100644 --- a/packages/ios-client/ios/app/VoltraLiveActivityService.swift +++ b/packages/ios-client/ios/app/VoltraLiveActivityService.swift @@ -294,7 +294,7 @@ public class VoltraLiveActivityService { throw VoltraDynamicLiveActivityError.unknownDefinition(request.definitionId) } do { - let source = try VoltraDynamicLiveActivityBundleSource.load(definitionId: request.definitionId) + let source = try await VoltraDynamicLiveActivityBundleSource.loadForApp(definitionId: request.definitionId) guard VoltraJSRenderer.evaluateLiveActivityBundle(source: source, definitionId: request.definitionId) else { throw VoltraDynamicLiveActivityError.resourceUnavailable( NSError(domain: "VoltraDynamicLiveActivity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Dynamic Live Activity bundle could not be evaluated."]) @@ -338,7 +338,7 @@ public class VoltraLiveActivityService { for definitionId in ids.sorted() { guard VoltraDynamicLiveActivityRegistry.shared.contains(definitionId) else { continue } do { - let source = try VoltraDynamicLiveActivityBundleSource.load(definitionId: definitionId) + let source = try await VoltraDynamicLiveActivityBundleSource.loadForApp(definitionId: definitionId) guard VoltraJSRenderer.evaluateLiveActivityBundle(source: source, definitionId: definitionId) else { throw VoltraDynamicLiveActivityError.resourceUnavailable( NSError(domain: "VoltraDynamicLiveActivity", code: -2, userInfo: [NSLocalizedDescriptionKey: "Dynamic Live Activity bundle could not be evaluated."]) diff --git a/packages/ios-client/ios/app/VoltraViewComponentView.mm b/packages/ios-client/ios/app/VoltraViewComponentView.mm index 5cff77e2..1204305c 100644 --- a/packages/ios-client/ios/app/VoltraViewComponentView.mm +++ b/packages/ios-client/ios/app/VoltraViewComponentView.mm @@ -1,9 +1,9 @@ #import "VoltraViewComponentView.h" -#if __has_include("Voltra/Voltra-Swift.h") -#import "Voltra/Voltra-Swift.h" +#if __has_include("Voltra/VoltraRuntime-Swift.h") +#import "Voltra/VoltraRuntime-Swift.h" #else -#import "Voltra-Swift.h" +#import "VoltraRuntime-Swift.h" #endif #import diff --git a/packages/ios-client/ios/shared/VoltraJSRenderer.swift b/packages/ios-client/ios/shared/VoltraJSRenderer.swift index 85807eb2..d35309ce 100644 --- a/packages/ios-client/ios/shared/VoltraJSRenderer.swift +++ b/packages/ios-client/ios/shared/VoltraJSRenderer.swift @@ -15,6 +15,7 @@ import JavaScriptCore /// indefinitely. public enum VoltraJSRenderer { private static var _context: JSContext? + private static var liveActivitySources: [String: String] = [:] private static let lock = NSLock() private static let TAG = "VoltraJSRenderer" @@ -37,12 +38,18 @@ public enum VoltraJSRenderer { /// are captured separately from Dynamic Widgets so both collections may use the /// same definition ID without overwriting one another. public static func evaluateLiveActivityBundle(source: String, definitionId: String) -> Bool { - evaluateBundle( + let evaluated = evaluateBundle( source: source, id: definitionId, registryName: "__voltraDynamicLiveActivities", kind: "live activity" ) + if evaluated { + lock.lock() + liveActivitySources[definitionId] = source + lock.unlock() + } + return evaluated } private static func evaluateBundle(source: String, id: String, registryName: String, kind: String) -> Bool { @@ -110,12 +117,17 @@ public enum VoltraJSRenderer { } public static func ensureLiveActivityEvaluated(definitionId: String, source: String) -> Bool { - ensureEvaluated( - source: source, - id: definitionId, - registryName: "__voltraDynamicLiveActivities", - kind: "live activity" - ) + lock.lock() + let currentSource = liveActivitySources[definitionId] + let hasRender = + _context? + .objectForKeyedSubscript("__voltraDynamicLiveActivities")? + .objectForKeyedSubscript(definitionId)? + .objectForKeyedSubscript("render")? + .isObject ?? false + lock.unlock() + if hasRender, currentSource == source { return true } + return evaluateLiveActivityBundle(source: source, definitionId: definitionId) } private static func ensureEvaluated(source: String, id: String, registryName: String, kind: String) -> Bool { diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityBundleSource.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityBundleSource.swift index 16fb6fbf..6f525763 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityBundleSource.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityBundleSource.swift @@ -1,39 +1,78 @@ import Foundation enum VoltraDynamicLiveActivityBundleSource { + private static let metroTimeout: TimeInterval = 1 + enum LoadError: LocalizedError { case metroHTTP(Int) + case metroTimeout case nonUTF8 case bakedBundleNotFound(definitionId: String) + case cacheUnavailable var errorDescription: String? { switch self { case let .metroHTTP(status): return "Metro HTTP \(status) while loading a Dynamic Live Activity bundle" + case .metroTimeout: + return "Metro timed out while loading a Dynamic Live Activity bundle" case .nonUTF8: return "Dynamic Live Activity bundle was not UTF-8 text" case let .bakedBundleNotFound(definitionId): return "Production Dynamic Live Activity bundle missing for definitionId=\(definitionId)" + case .cacheUnavailable: + return "Dynamic Live Activity debug cache is unavailable" } } } static func load(definitionId: String) throws -> String { #if DEBUG - return try loadFromMetro(definitionId: definitionId) + if let cached = try? loadFromDebugCache(definitionId: definitionId) { + return cached + } + return try loadFromMetroBounded(definitionId: definitionId) + #else + return try loadFromBakedAsset(definitionId: definitionId) + #endif + } + + /// App-side preflight and Fast Refresh use an asynchronous fetch, then publish + /// the source into the App Group cache consumed by the extension process. + static func loadForApp(definitionId: String) async throws -> String { + #if DEBUG + let request = try metroRequest(definitionId: definitionId) + let (data, response) = try await URLSession.shared.data(for: request) + let source = try validateMetroResponse(data: data, response: response) + try saveToDebugCache(source, definitionId: definitionId) + return source #else return try loadFromBakedAsset(definitionId: definitionId) #endif } - private static func loadFromMetro(definitionId: String) throws -> String { + private static func metroRequest(definitionId: String) throws -> URLRequest { let base = VoltraWidgetDefaults.devServerURL() ?? "http://localhost:8081" guard let url = URL(string: "\(base)/voltra/live-activities/\(definitionId).bundle?platform=ios&dev=true") else { throw LoadError.metroHTTP(-1) } + var request = URLRequest(url: url) + request.timeoutInterval = metroTimeout + return request + } + + /// WidgetKit rendering is synchronous. On a cold debug process we permit one + /// short, bounded bootstrap request; all subsequent renders read the App Group + /// cache and never wait on the network. + private static func loadFromMetroBounded(definitionId: String) throws -> String { + let request = try metroRequest(definitionId: definitionId) let semaphore = DispatchSemaphore(value: 0) var result: Result<(Data, URLResponse), Error>? - URLSession.shared.dataTask(with: url) { data, response, error in + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = metroTimeout + configuration.timeoutIntervalForResource = metroTimeout + let session = URLSession(configuration: configuration) + let task = session.dataTask(with: request) { data, response, error in if let error { result = .failure(error) } else if let data, let response { @@ -42,9 +81,22 @@ enum VoltraDynamicLiveActivityBundleSource { result = .failure(LoadError.metroHTTP(-1)) } semaphore.signal() - }.resume() - semaphore.wait() - let (data, response) = try result!.get() + } + task.resume() + guard semaphore.wait(timeout: .now() + metroTimeout) == .success else { + task.cancel() + session.invalidateAndCancel() + throw LoadError.metroTimeout + } + session.finishTasksAndInvalidate() + guard let result else { throw LoadError.metroTimeout } + let (data, response) = try result.get() + let source = try validateMetroResponse(data: data, response: response) + try saveToDebugCache(source, definitionId: definitionId) + return source + } + + private static func validateMetroResponse(data: Data, response: URLResponse) throws -> String { if let httpResponse = response as? HTTPURLResponse, !(200 ... 299).contains(httpResponse.statusCode) { throw LoadError.metroHTTP(httpResponse.statusCode) } @@ -54,6 +106,24 @@ enum VoltraDynamicLiveActivityBundleSource { return source } + private static func loadFromDebugCache(definitionId: String) throws -> String { + try String(contentsOf: debugCacheURL(definitionId: definitionId), encoding: .utf8) + } + + private static func saveToDebugCache(_ source: String, definitionId: String) throws { + try Data(source.utf8).write(to: debugCacheURL(definitionId: definitionId), options: .atomic) + } + + private static func debugCacheURL(definitionId: String) throws -> URL { + guard + let group = VoltraConfig.groupIdentifier(), + let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) + else { + throw LoadError.cacheUnavailable + } + return container.appendingPathComponent("voltra-live-activity-debug-\(definitionId).bundle") + } + private static func loadFromBakedAsset(definitionId: String) throws -> String { guard let url = Bundle.main.url(forResource: "voltra-live-activity-\(definitionId)", withExtension: "bundle"), From 4a63a4125cdc71b28d56e19256a9323ee8a89ce7 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 10:31:11 +0200 Subject: [PATCH 17/24] fix(ios): stabilize live activity error codes --- packages/ios-client/ios/app/NativeVoltra.mm | 25 ++++++++++---- .../ios-client/ios/app/VoltraModule.swift | 33 ++++++++++++++++++- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/ios-client/ios/app/NativeVoltra.mm b/packages/ios-client/ios/app/NativeVoltra.mm index c0bcc8e7..a8e3206c 100644 --- a/packages/ios-client/ios/app/NativeVoltra.mm +++ b/packages/ios-client/ios/app/NativeVoltra.mm @@ -7,6 +7,19 @@ #import "VoltraRuntime-Swift.h" #endif +static NSString *VoltraPromiseErrorCode(NSString *fallbackCode, NSError *error) +{ + if ([error.domain isEqualToString:@"com.callstack.voltra"] && error.code == 4) { + return @"VOLTRA_RENDERER_MISMATCH"; + } + return fallbackCode; +} + +static void VoltraRejectPromise(RCTPromiseRejectBlock reject, NSString *fallbackCode, NSError *error) +{ + reject(VoltraPromiseErrorCode(fallbackCode, error), error.localizedDescription, error); +} + @interface VoltraLaunchObserver : NSObject @end @@ -201,7 +214,7 @@ - (void)startLiveActivity:(NSString *)jsonString if (auto v = options.staleDate()) opts.staleDate = @(v.value()); if (auto v = options.relevanceScore()) opts.relevanceScore = @(v.value()); [self.module startLiveActivity:jsonString options:opts completion:^(NSString *activityId, NSError *error) { - if (error) { reject(@"startLiveActivity", error.localizedDescription, error); } else { resolve(activityId); } + if (error) { VoltraRejectPromise(reject, @"startLiveActivity", error); } else { resolve(activityId); } }]; } @@ -215,7 +228,7 @@ - (void)updateLiveActivity:(NSString *)activityId if (auto v = options.staleDate()) opts.staleDate = @(v.value()); if (auto v = options.relevanceScore()) opts.relevanceScore = @(v.value()); [self.module updateLiveActivity:activityId jsonString:jsonString options:opts completion:^(NSError *error) { - if (error) { reject(@"updateLiveActivity", error.localizedDescription, error); } else { resolve(nil); } + if (error) { VoltraRejectPromise(reject, @"updateLiveActivity", error); } else { resolve(nil); } }]; } @@ -232,7 +245,7 @@ - (void)startDynamicLiveActivity:(NSString *)definitionId if (auto v = options.staleDate()) opts.staleDate = @(v.value()); if (auto v = options.relevanceScore()) opts.relevanceScore = @(v.value()); [self.module startDynamicLiveActivity:definitionId propsJson:propsJson options:opts completion:^(NSString *activityId, NSError *error) { - if (error) { reject(@"startDynamicLiveActivity", error.localizedDescription, error); } else { resolve(activityId); } + if (error) { VoltraRejectPromise(reject, @"startDynamicLiveActivity", error); } else { resolve(activityId); } }]; } @@ -246,7 +259,7 @@ - (void)updateDynamicLiveActivity:(NSString *)activityId if (auto v = options.staleDate()) opts.staleDate = @(v.value()); if (auto v = options.relevanceScore()) opts.relevanceScore = @(v.value()); [self.module updateDynamicLiveActivity:activityId propsJson:propsJson options:opts completion:^(NSError *error) { - if (error) { reject(@"updateDynamicLiveActivity", error.localizedDescription, error); } else { resolve(nil); } + if (error) { VoltraRejectPromise(reject, @"updateDynamicLiveActivity", error); } else { resolve(nil); } }]; } @@ -263,14 +276,14 @@ - (void)endLiveActivity:(NSString *)activityId opts.dismissalPolicy = policy; } [self.module endLiveActivity:activityId options:opts completion:^(NSError *error) { - if (error) { reject(@"endLiveActivity", error.localizedDescription, error); } else { resolve(nil); } + if (error) { VoltraRejectPromise(reject, @"endLiveActivity", error); } else { resolve(nil); } }]; } - (void)endAllLiveActivities:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { [self.module endAllLiveActivities:^(NSError *error) { - if (error) { reject(@"endAllLiveActivities", error.localizedDescription, error); } else { resolve(nil); } + if (error) { VoltraRejectPromise(reject, @"endAllLiveActivities", error); } else { resolve(nil); } }]; } diff --git a/packages/ios-client/ios/app/VoltraModule.swift b/packages/ios-client/ios/app/VoltraModule.swift index ef954cc3..8a15eefd 100644 --- a/packages/ios-client/ios/app/VoltraModule.swift +++ b/packages/ios-client/ios/app/VoltraModule.swift @@ -1,11 +1,42 @@ import Foundation -public enum VoltraErrors: Error { +public enum VoltraErrors: Error, CustomNSError { case unsupportedOS case notFound case liveActivitiesNotEnabled case rendererMismatch case unexpectedError(Error) + + public static let errorDomain = "com.callstack.voltra" + + public var errorCode: Int { + switch self { + case .unexpectedError: 0 + case .unsupportedOS: 1 + case .notFound: 2 + case .liveActivitiesNotEnabled: 3 + case .rendererMismatch: 4 + } + } + + public var errorUserInfo: [String: Any] { + [NSLocalizedDescriptionKey: errorDescription] + } + + private var errorDescription: String { + switch self { + case .unsupportedOS: + "Live Activities require iOS 16.4 or newer." + case .notFound: + "The requested Live Activity was not found." + case .liveActivitiesNotEnabled: + "Live Activities are disabled for this app." + case .rendererMismatch: + "The Live Activity belongs to a different renderer." + case let .unexpectedError(error): + error.localizedDescription + } + } } @objc public final class VoltraModule: NSObject { From 02dcb61d974c33af6fad304db785881928cf19dd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 10:33:39 +0200 Subject: [PATCH 18/24] refactor: centralize dynamic activity type naming --- packages/core/src/dynamic-live-activity.ts | 13 +++++++++++++ packages/core/src/index.ts | 1 + packages/expo-plugin/jest.config.js | 4 ++++ packages/expo-plugin/package.json | 3 ++- .../src/dynamic-live-activity.node.test.ts | 18 ++++++++++++++++++ .../expo-plugin/src/dynamic-live-activity.ts | 14 +------------- packages/ios-client/expo-plugin/jest.config.js | 1 + packages/ios/src/live-activity/dynamic.ts | 14 +------------- pnpm-lock.yaml | 3 +++ 9 files changed, 44 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/dynamic-live-activity.ts create mode 100644 packages/expo-plugin/src/dynamic-live-activity.node.test.ts diff --git a/packages/core/src/dynamic-live-activity.ts b/packages/core/src/dynamic-live-activity.ts new file mode 100644 index 00000000..3dbd2a5a --- /dev/null +++ b/packages/core/src/dynamic-live-activity.ts @@ -0,0 +1,13 @@ +/** + * Returns the generated ActivityKit attributes type name for a Dynamic Live Activity definition ID. + * @experimental + */ +export function getDynamicLiveActivityAttributesType(definitionId: string): string { + const upperCamelCaseId = definitionId + .split('_') + .filter(Boolean) + .map((segment) => `${segment[0].toUpperCase()}${segment.slice(1)}`) + .join('') + + return `Voltra${upperCamelCaseId}LiveActivityAttributes` +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 442aa610..b110948e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export * from './jsx/createVoltraComponent.js' +export * from './dynamic-live-activity.js' export * from './payload.js' export * from './payload/short-names.js' export * from './renderer/index.js' diff --git a/packages/expo-plugin/jest.config.js b/packages/expo-plugin/jest.config.js index dcdfc6cb..a7cb36f2 100644 --- a/packages/expo-plugin/jest.config.js +++ b/packages/expo-plugin/jest.config.js @@ -3,6 +3,10 @@ module.exports = { testEnvironment: 'node', testMatch: ['/src/**/*.node.test.ts'], modulePathIgnorePatterns: ['/build'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + '^@use-voltra/core$': '/../core/src/index.ts', + }, transform: { '^.+\\.tsx?$': [ 'ts-jest', diff --git a/packages/expo-plugin/package.json b/packages/expo-plugin/package.json index 9c7b91b9..a85a2a71 100644 --- a/packages/expo-plugin/package.json +++ b/packages/expo-plugin/package.json @@ -27,7 +27,8 @@ "typecheck": "tsc -p tsconfig.typecheck.json --noEmit" }, "dependencies": { - "@babel/core": "^7.27.4" + "@babel/core": "^7.27.4", + "@use-voltra/core": "workspace:^" }, "keywords": [ "voltra", diff --git a/packages/expo-plugin/src/dynamic-live-activity.node.test.ts b/packages/expo-plugin/src/dynamic-live-activity.node.test.ts new file mode 100644 index 00000000..caa41b0c --- /dev/null +++ b/packages/expo-plugin/src/dynamic-live-activity.node.test.ts @@ -0,0 +1,18 @@ +import { getDynamicLiveActivityAttributesType as getCoreAttributesType } from '../../core/src/dynamic-live-activity' +import { getDynamicLiveActivityAttributesType as getIOSAttributesType } from '../../ios/src/live-activity/dynamic' + +import { getDynamicLiveActivityAttributesType as getExpoPluginAttributesType } from './dynamic-live-activity' + +describe('Dynamic Live Activity attributes type naming', () => { + it.each([ + ['', 'VoltraLiveActivityAttributes'], + ['order_finished', 'VoltraOrderFinishedLiveActivityAttributes'], + ['order__finished', 'VoltraOrderFinishedLiveActivityAttributes'], + ['_driver_arrived_', 'VoltraDriverArrivedLiveActivityAttributes'], + ['alreadyCamel', 'VoltraAlreadyCamelLiveActivityAttributes'], + ])('keeps core, Expo generation, and the iOS public helper aligned for %p', (definitionId, expected) => { + expect(getCoreAttributesType(definitionId)).toBe(expected) + expect(getExpoPluginAttributesType(definitionId)).toBe(expected) + expect(getIOSAttributesType(definitionId)).toBe(expected) + }) +}) diff --git a/packages/expo-plugin/src/dynamic-live-activity.ts b/packages/expo-plugin/src/dynamic-live-activity.ts index d6657a6a..3536ab72 100644 --- a/packages/expo-plugin/src/dynamic-live-activity.ts +++ b/packages/expo-plugin/src/dynamic-live-activity.ts @@ -1,13 +1 @@ -/** - * Returns the generated ActivityKit attributes type name for a definition ID. - * @experimental - */ -export function getDynamicLiveActivityAttributesType(definitionId: string): string { - const upperCamelCaseId = definitionId - .split('_') - .filter(Boolean) - .map((segment) => `${segment[0].toUpperCase()}${segment.slice(1)}`) - .join('') - - return `Voltra${upperCamelCaseId}LiveActivityAttributes` -} +export { getDynamicLiveActivityAttributesType } from '@use-voltra/core' diff --git a/packages/ios-client/expo-plugin/jest.config.js b/packages/ios-client/expo-plugin/jest.config.js index a46d2d65..cbbd2d59 100644 --- a/packages/ios-client/expo-plugin/jest.config.js +++ b/packages/ios-client/expo-plugin/jest.config.js @@ -6,6 +6,7 @@ module.exports = { moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', '^@use-voltra/compiler$': '/../../compiler/src/index.ts', + '^@use-voltra/core$': '/../../core/src/index.ts', '^@use-voltra/expo-plugin$': '/../../expo-plugin/src/index.ts', '^@use-voltra/expo-plugin/(.*)$': '/../../expo-plugin/src/$1', '^@use-voltra/metro/scanner$': '/../../metro/src/scanner.ts', diff --git a/packages/ios/src/live-activity/dynamic.ts b/packages/ios/src/live-activity/dynamic.ts index f83df663..887fbaa8 100644 --- a/packages/ios/src/live-activity/dynamic.ts +++ b/packages/ios/src/live-activity/dynamic.ts @@ -21,16 +21,4 @@ export interface DynamicLiveActivityContentState { props: DynamicLiveActivityProps } -/** - * Returns the generated ActivityKit attributes type name for a definition ID. - * @experimental - */ -export function getDynamicLiveActivityAttributesType(definitionId: string): string { - const upperCamelCaseId = definitionId - .split('_') - .filter(Boolean) - .map((segment) => `${segment[0].toUpperCase()}${segment.slice(1)}`) - .join('') - - return `Voltra${upperCamelCaseId}LiveActivityAttributes` -} +export { getDynamicLiveActivityAttributesType } from '@use-voltra/core' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e228fedc..0cd807c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,6 +342,9 @@ importers: '@babel/core': specifier: ^7.27.4 version: 7.29.0 + '@use-voltra/core': + specifier: workspace:^ + version: link:../core expo: specifier: '*' version: 55.0.25(@babel/core@7.29.0)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.15)(react-dom@19.2.4(react@19.2.4))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.15)(react@19.2.4))(react@19.2.4))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.15)(react@19.2.4))(react@19.2.4))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.15)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) From 8d727d7bfe0621ebdf1131541df61da175a3a986 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 10:35:49 +0200 Subject: [PATCH 19/24] refactor(ios): isolate dynamic activity Swift generation --- .../ios-widget/dynamic-live-activity/swift.ts | 139 +++++++++++++++++ .../src/ios-widget/files/swift-utils.ts | 3 + .../expo-plugin/src/ios-widget/files/swift.ts | 141 +----------------- 3 files changed, 150 insertions(+), 133 deletions(-) create mode 100644 packages/ios-client/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts create mode 100644 packages/ios-client/expo-plugin/src/ios-widget/files/swift-utils.ts diff --git a/packages/ios-client/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts b/packages/ios-client/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts new file mode 100644 index 00000000..9899aefb --- /dev/null +++ b/packages/ios-client/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts @@ -0,0 +1,139 @@ +import dedent from 'dedent' + +import { getDynamicLiveActivityAttributesType } from '@use-voltra/expo-plugin' + +import type { IOSDynamicLiveActivityConfig } from '../../types' +import { escapeForSwiftStringLiteral } from '../files/swift-utils' + +export function generateDynamicLiveActivityWidgetInstances(liveActivities: IOSDynamicLiveActivityConfig[]): string { + return sortedDefinitions(liveActivities) + .map((liveActivity) => `VoltraDynamicLiveActivity_${getDynamicLiveActivityAttributesType(liveActivity.id)}()`) + .join('\n ') +} + +/** Generates the Dynamic Live Activity types shared by the app and extension targets. */ +export function generateDynamicLiveActivityTypesSwift(liveActivities: IOSDynamicLiveActivityConfig[]): string { + const definitions = sortedDefinitions(liveActivities) + const typeDefinitions = definitions.map(generateDynamicLiveActivitySwift).map(normalizeIndent).join('\n\n') + + const header = dedent` + // + // VoltraDynamicLiveActivityTypes.swift + // + // Auto-generated by Voltra config plugin. Do not edit. + // + + import ActivityKit + import Foundation + + import VoltraRuntime + + @objc(VoltraGeneratedDynamicLiveActivityRegistration) + public final class VoltraGeneratedDynamicLiveActivityRegistration: NSObject { + @objc public static func registerDefinitions() { +${definitions + .map( + (liveActivity) => + ` VoltraDynamicLiveActivityRegistry.shared.register(${getDynamicLiveActivityAttributesType( + liveActivity.id + )}.self)` + ) + .join('\n')} + } + } + + ` + return [header.trim(), typeDefinitions.trim()].filter(Boolean).join('\n\n') +} + +/** Generates extension-only ActivityConfiguration declarations for Dynamic Live Activities. */ +export function generateDynamicLiveActivitiesSwift(liveActivities: IOSDynamicLiveActivityConfig[]): string { + const configurations = sortedDefinitions(liveActivities) + .map((liveActivity) => { + const attributesType = getDynamicLiveActivityAttributesType(liveActivity.id) + const definitionId = escapeForSwiftStringLiteral(liveActivity.id) + return dedent` + public struct VoltraDynamicLiveActivity_${attributesType}: Widget { + public init() {} + + public var body: some WidgetConfiguration { + if #available(iOS 18.0, *) { + return adaptiveConfig() + } else { + return defaultConfig() + } + } + + @available(iOS 18.0, *) + private func adaptiveConfig() -> some WidgetConfiguration { + ActivityConfiguration(for: ${attributesType}.self) { context in + VoltraDynamicLiveActivityRenderer.lockScreen(definitionId: "${definitionId}", context: context) + } dynamicIsland: { context in + VoltraDynamicLiveActivityRenderer.dynamicIsland(definitionId: "${definitionId}", context: context) + } + .supplementalActivityFamilies([.small, .medium]) + } + + private func defaultConfig() -> some WidgetConfiguration { + ActivityConfiguration(for: ${attributesType}.self) { context in + VoltraDynamicLiveActivityRenderer.lockScreen(definitionId: "${definitionId}", context: context) + } dynamicIsland: { context in + VoltraDynamicLiveActivityRenderer.dynamicIsland(definitionId: "${definitionId}", context: context) + } + } + } + ` + }) + .map(normalizeIndent) + .join('\n\n') + + const header = dedent` + // + // VoltraDynamicLiveActivities.swift + // + // Auto-generated by Voltra config plugin. Do not edit. + // + + import ActivityKit + import SwiftUI + import WidgetKit + import VoltraRuntime + + ` + return [header.trim(), configurations.trim()].filter(Boolean).join('\n\n') +} + +function generateDynamicLiveActivitySwift(liveActivity: IOSDynamicLiveActivityConfig): string { + const attributesType = getDynamicLiveActivityAttributesType(liveActivity.id) + const definitionId = escapeForSwiftStringLiteral(liveActivity.id) + + return dedent` + public struct ${attributesType}: ActivityAttributes { + public typealias ContentState = VoltraDynamicLiveActivityContentState + + public let name: String + public let deepLinkUrl: String? + + public init(name: String, deepLinkUrl: String?) { + self.name = name + self.deepLinkUrl = deepLinkUrl + } + } + + extension ${attributesType}: VoltraDynamicLiveActivityDefinition { + public static let definitionId = "${definitionId}" + public static let attributesTypeName = "${attributesType}" + } + + ` +} + +function sortedDefinitions(liveActivities: IOSDynamicLiveActivityConfig[]): IOSDynamicLiveActivityConfig[] { + return [...liveActivities].sort((left, right) => left.id.localeCompare(right.id)) +} + +function normalizeIndent(source: string): string { + const lines = source.trim().split('\n') + const indent = Math.min(...lines.filter(Boolean).map((line) => line.match(/^\s*/)?.[0].length ?? 0)) + return lines.map((line) => line.slice(indent)).join('\n') +} diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift-utils.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift-utils.ts new file mode 100644 index 00000000..66e8b412 --- /dev/null +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift-utils.ts @@ -0,0 +1,3 @@ +export function escapeForSwiftStringLiteral(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r') +} 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 de7812eb..3f1f57f0 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 @@ -3,7 +3,6 @@ import * as fs from 'fs' import * as path from 'path' import { - getDynamicLiveActivityAttributesType, isWidgetLocalizedMap, logger, prerenderWidgetState, @@ -17,6 +16,13 @@ import type { IOSDynamicLiveActivityConfig, IOSWidgetConfig } from '../../types' import { VOLTRA_WIDGET_STRINGS_BASENAME } from '../../utils/fileDiscovery' import { detectClientRenderedWidgets, type DetectedIOSWidget } from '../clientRendered' import { prerenderClientRenderedWidgets } from '../clientRenderedPrerender' +import { + generateDynamicLiveActivitiesSwift, + generateDynamicLiveActivityTypesSwift, + generateDynamicLiveActivityWidgetInstances, +} from '../dynamic-live-activity/swift' + +import { escapeForSwiftStringLiteral } from './swift-utils' export interface GenerateSwiftFilesOptions { targetPath: string @@ -169,10 +175,6 @@ const GENERATED_INITIAL_STATE_LOCALE_HELPER = dedent` // https://developer.apple.com/documentation/foundation/localizedstringresource // ============================================================================ -function escapeForSwiftStringLiteral(s: string): string { - return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r') -} - function escapeDotStringsValue(s: string): string { return escapeForSwiftStringLiteral(s) } @@ -474,10 +476,7 @@ function generateWidgetBundleSwift( .map((w) => `VoltraWidget_${w.id}()`) .join('\n ')}\n }` : '' - const dynamicLiveActivityInstances = [...liveActivities] - .sort((left, right) => left.id.localeCompare(right.id)) - .map((liveActivity) => `VoltraDynamicLiveActivity_${getDynamicLiveActivityAttributesType(liveActivity.id)}()`) - .join('\n ') + const dynamicLiveActivityInstances = generateDynamicLiveActivityWidgetInstances(liveActivities) const widgetInstances = [plainInstances, appIntentInstances, dynamicLiveActivityInstances] .filter(Boolean) .join('\n ') @@ -544,130 +543,6 @@ function generateDefaultWidgetBundleSwift(): string { ` } -/** Generates the Dynamic Live Activity types shared by the app and extension targets. */ -function generateDynamicLiveActivityTypesSwift(liveActivities: IOSDynamicLiveActivityConfig[]): string { - const definitions = [...liveActivities].sort((left, right) => left.id.localeCompare(right.id)) - const typeDefinitions = definitions.map(generateDynamicLiveActivitySwift).map(indentGeneratedSwift).join('\n\n') - - const header = dedent` - // - // VoltraDynamicLiveActivityTypes.swift - // - // Auto-generated by Voltra config plugin. Do not edit. - // - - import ActivityKit - import Foundation - - import VoltraRuntime - - @objc(VoltraGeneratedDynamicLiveActivityRegistration) - public final class VoltraGeneratedDynamicLiveActivityRegistration: NSObject { - @objc public static func registerDefinitions() { -${definitions - .map( - (liveActivity) => - ` VoltraDynamicLiveActivityRegistry.shared.register(${getDynamicLiveActivityAttributesType( - liveActivity.id - )}.self)` - ) - .join('\n')} - } - } - - ` - return [header.trim(), typeDefinitions.trim()].filter(Boolean).join('\n\n') -} - -function generateDynamicLiveActivitySwift(liveActivity: IOSDynamicLiveActivityConfig): string { - const attributesType = getDynamicLiveActivityAttributesType(liveActivity.id) - const definitionId = escapeForSwiftStringLiteral(liveActivity.id) - - return dedent` - public struct ${attributesType}: ActivityAttributes { - public typealias ContentState = VoltraDynamicLiveActivityContentState - - public let name: String - public let deepLinkUrl: String? - - public init(name: String, deepLinkUrl: String?) { - self.name = name - self.deepLinkUrl = deepLinkUrl - } - } - - extension ${attributesType}: VoltraDynamicLiveActivityDefinition { - public static let definitionId = "${definitionId}" - public static let attributesTypeName = "${attributesType}" - } - - ` -} - -/** Generates extension-only ActivityConfiguration declarations for Dynamic Live Activities. */ -function generateDynamicLiveActivitiesSwift(liveActivities: IOSDynamicLiveActivityConfig[]): string { - const definitions = [...liveActivities].sort((left, right) => left.id.localeCompare(right.id)) - const configurations = definitions - .map((liveActivity) => { - const attributesType = getDynamicLiveActivityAttributesType(liveActivity.id) - const definitionId = escapeForSwiftStringLiteral(liveActivity.id) - return dedent` - public struct VoltraDynamicLiveActivity_${attributesType}: Widget { - public init() {} - - public var body: some WidgetConfiguration { - if #available(iOS 18.0, *) { - return adaptiveConfig() - } else { - return defaultConfig() - } - } - - @available(iOS 18.0, *) - private func adaptiveConfig() -> some WidgetConfiguration { - ActivityConfiguration(for: ${attributesType}.self) { context in - VoltraDynamicLiveActivityRenderer.lockScreen(definitionId: "${definitionId}", context: context) - } dynamicIsland: { context in - VoltraDynamicLiveActivityRenderer.dynamicIsland(definitionId: "${definitionId}", context: context) - } - .supplementalActivityFamilies([.small, .medium]) - } - - private func defaultConfig() -> some WidgetConfiguration { - ActivityConfiguration(for: ${attributesType}.self) { context in - VoltraDynamicLiveActivityRenderer.lockScreen(definitionId: "${definitionId}", context: context) - } dynamicIsland: { context in - VoltraDynamicLiveActivityRenderer.dynamicIsland(definitionId: "${definitionId}", context: context) - } - } - } - ` - }) - .map(indentGeneratedSwift) - .join('\n\n') - - const header = dedent` - // - // VoltraDynamicLiveActivities.swift - // - // Auto-generated by Voltra config plugin. Do not edit. - // - - import ActivityKit - import SwiftUI - import WidgetKit - import VoltraRuntime - - ` - return [header.trim(), configurations.trim()].filter(Boolean).join('\n\n') -} - -function indentGeneratedSwift(source: string): string { - const lines = source.trim().split('\n') - const indent = Math.min(...lines.filter(Boolean).map((line) => line.match(/^\s*/)?.[0].length ?? 0)) - return lines.map((line) => line.slice(indent)).join('\n') -} - // ============================================================================ // Initial States // ============================================================================ From 624ef97e9d63f8e112c27a8a3526f759f7beabcf Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 10:39:57 +0200 Subject: [PATCH 20/24] refactor(ios): isolate dynamic activity lifecycle --- packages/ios-client/ios/Package.swift | 1 + .../VoltraLiveActivityOrderTests.swift | 24 +++++ .../ios/app/VoltraLiveActivityManager.swift | 7 ++ .../ios/app/VoltraLiveActivityService.swift | 74 ++++---------- .../VoltraDynamicLiveActivityService.swift | 97 +++++++++++++++++++ .../VoltraLiveActivityChronology.swift | 47 +++++++++ .../VoltraDynamicLiveActivityObserver.swift | 4 + .../VoltraLiveActivityOrder.swift | 19 ++++ 8 files changed, 219 insertions(+), 54 deletions(-) create mode 100644 packages/ios-client/ios/Tests/VoltraSharedTests/VoltraLiveActivityOrderTests.swift create mode 100644 packages/ios-client/ios/app/dynamic-live-activity/VoltraDynamicLiveActivityService.swift create mode 100644 packages/ios-client/ios/app/dynamic-live-activity/VoltraLiveActivityChronology.swift create mode 100644 packages/ios-client/ios/shared/dynamic-live-activity/VoltraLiveActivityOrder.swift diff --git a/packages/ios-client/ios/Package.swift b/packages/ios-client/ios/Package.swift index b87af345..5e779927 100644 --- a/packages/ios-client/ios/Package.swift +++ b/packages/ios-client/ios/Package.swift @@ -45,6 +45,7 @@ let package = Package( "dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift", "dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift", "dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift", + "dynamic-live-activity/VoltraLiveActivityOrder.swift", "JSONValue.swift", "VoltraConfig.swift", "VoltraConstants.swift", diff --git a/packages/ios-client/ios/Tests/VoltraSharedTests/VoltraLiveActivityOrderTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/VoltraLiveActivityOrderTests.swift new file mode 100644 index 00000000..5621d17f --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/VoltraLiveActivityOrderTests.swift @@ -0,0 +1,24 @@ +@testable import VoltraSharedCore +import XCTest + +final class VoltraLiveActivityOrderTests: XCTestCase { + func testPreservesKnownCrossEngineOrderAndAppendsDiscoveries() { + XCTAssertEqual( + VoltraLiveActivityOrder.reconcile( + previous: ["dynamic-first", "legacy-second"], + active: ["legacy-second", "legacy-new", "dynamic-first", "dynamic-new"] + ), + ["dynamic-first", "legacy-second", "legacy-new", "dynamic-new"] + ) + } + + func testPrunesEndedActivitiesAndDuplicateDiscoveries() { + XCTAssertEqual( + VoltraLiveActivityOrder.reconcile( + previous: ["ended", "active", "active"], + active: ["active", "new", "new"] + ), + ["active", "new"] + ) + } +} diff --git a/packages/ios-client/ios/app/VoltraLiveActivityManager.swift b/packages/ios-client/ios/app/VoltraLiveActivityManager.swift index a9785565..24e78b2d 100644 --- a/packages/ios-client/ios/app/VoltraLiveActivityManager.swift +++ b/packages/ios-client/ios/app/VoltraLiveActivityManager.swift @@ -13,6 +13,9 @@ public actor VoltraLiveActivityManager { // Callbacks are `let` + `@Sendable` so they are immutable after init and safe // to call from any concurrency context without capturing `self`. + /// Called when ActivityKit exposes an activity instance to this process. + private let onActivityDiscovered: (@Sendable (String) -> Void)? + /// Called when a push token is received or rotated for a specific activity. /// Parameters: (activityName, hexToken) private let onTokenUpdated: (@Sendable (String, String) -> Void)? @@ -58,14 +61,17 @@ public actor VoltraLiveActivityManager { // MARK: - Init public init( + onActivityDiscovered: (@Sendable (String) -> Void)? = nil, onTokenUpdated: (@Sendable (String, String) -> Void)? = nil, onPushToStartUpdated: (@Sendable (String) -> Void)? = nil, onStateChanged: (@Sendable (String, String) -> Void)? = nil ) { + self.onActivityDiscovered = onActivityDiscovered self.onTokenUpdated = onTokenUpdated self.onPushToStartUpdated = onPushToStartUpdated self.onStateChanged = onStateChanged dynamicObserver = VoltraDynamicLiveActivityObserver( + onActivityDiscovered: onActivityDiscovered, onTokenUpdated: onTokenUpdated, onStateChanged: onStateChanged ) @@ -192,6 +198,7 @@ public actor VoltraLiveActivityManager { private func observe(_ activity: Activity) { let activityId = activity.id let activityName = activity.attributes.name + onActivityDiscovered?(activityId) // Token observation if let onTokenUpdated, tokenTasks[activityId] == nil { diff --git a/packages/ios-client/ios/app/VoltraLiveActivityService.swift b/packages/ios-client/ios/app/VoltraLiveActivityService.swift index ee33f264..b00baf85 100644 --- a/packages/ios-client/ios/app/VoltraLiveActivityService.swift +++ b/packages/ios-client/ios/app/VoltraLiveActivityService.swift @@ -78,6 +78,9 @@ public struct UpdateActivityRequest { /// Service for managing Voltra Live Activities public class VoltraLiveActivityService { + private let chronology = VoltraLiveActivityChronology.shared + private let dynamicService = VoltraDynamicLiveActivityService() + // MARK: - Availability Checks /// Check if Live Activities are supported on this OS version @@ -113,7 +116,7 @@ public class VoltraLiveActivityService { return Array(Activity.activities) } - /// Get the latest (most recently created) activity across both types + /// Get the latest legacy activity. public func getLatestActivity() -> VoltraActivity? { guard Self.isSupported() else { return nil } let allActivities = getAllActivities() @@ -122,7 +125,7 @@ public class VoltraLiveActivityService { /// Check if an activity with the given name exists across both types public func isActivityActive(name: String) -> Bool { - findActivity(byName: name) != nil || VoltraDynamicLiveActivityRegistry.shared.activities().contains { $0.name == name } + findActivity(byName: name) != nil || dynamicService.isActive(name: name) } /// The unified list intentionally erases each engine's concrete attributes type. @@ -131,7 +134,7 @@ public class VoltraLiveActivityService { let legacy = getAllActivities().map { VoltraDynamicLiveActivityReference(id: $0.id, name: $0.attributes.name, definitionId: "legacy") } - return legacy + VoltraDynamicLiveActivityRegistry.shared.activities() + return chronology.order(legacy + dynamicService.activityReferences()) } public func latestActivityId() -> String? { @@ -141,7 +144,7 @@ public class VoltraLiveActivityService { /// The installed capability list is generated during prebuild and does not /// depend on Metro, the app group, or a server connection. public func dynamicLiveActivityDefinitionIds() -> [String] { - VoltraDynamicLiveActivityRegistry.shared.definitionIds() + dynamicService.definitionIds() } // MARK: - Create Operations @@ -173,7 +176,7 @@ public class VoltraLiveActivityService { let initialState = try VoltraAttributes.ContentState(uiJsonData: request.jsonString) // Request the activity - _ = try Activity.request( + let activity = try Activity.request( attributes: attributes, content: .init( state: initialState, @@ -182,6 +185,7 @@ public class VoltraLiveActivityService { ), pushType: request.pushType ) + chronology.record(activity.id) return finalActivityId } @@ -221,7 +225,7 @@ public class VoltraLiveActivityService { request: UpdateActivityRequest ) async throws { guard let activity = findActivity(byName: name) else { - if VoltraDynamicLiveActivityRegistry.shared.activities().contains(where: { $0.name == name }) { + if dynamicService.isActive(name: name) { throw VoltraLiveActivityError.rendererMismatch } throw VoltraLiveActivityError.notFound @@ -256,10 +260,10 @@ public class VoltraLiveActivityService { if let activity = findActivity(byName: name) { await endActivity(activity, dismissalPolicy: dismissalPolicy) // Names can collide across engines after a remote start. Shared ending covers both. - _ = await VoltraDynamicLiveActivityRegistry.shared.end(byName: name, dismissalPolicy: dismissalPolicy) + _ = await dynamicService.end(byName: name, dismissalPolicy: dismissalPolicy) return } - guard await VoltraDynamicLiveActivityRegistry.shared.end(byName: name, dismissalPolicy: dismissalPolicy) else { + guard await dynamicService.end(byName: name, dismissalPolicy: dismissalPolicy) else { throw VoltraLiveActivityError.notFound } } @@ -272,7 +276,7 @@ public class VoltraLiveActivityService { for activity in activities { await endActivity(activity) } - _ = await VoltraDynamicLiveActivityRegistry.shared.end(byName: name, dismissalPolicy: .immediate) + _ = await dynamicService.end(byName: name, dismissalPolicy: .immediate) } /// End all Voltra Live Activities @@ -282,7 +286,7 @@ public class VoltraLiveActivityService { for activity in activities { await endActivity(activity) } - await VoltraDynamicLiveActivityRegistry.shared.endAll(dismissalPolicy: .immediate) + await dynamicService.endAll(dismissalPolicy: .immediate) } // MARK: - Dynamic operations @@ -290,39 +294,15 @@ public class VoltraLiveActivityService { public func createDynamicActivity(_ request: VoltraDynamicLiveActivityCreateRequest) async throws -> String { guard Self.isSupported() else { throw VoltraLiveActivityError.unsupportedOS } guard Self.areActivitiesEnabled() else { throw VoltraLiveActivityError.liveActivitiesNotEnabled } - guard VoltraDynamicLiveActivityRegistry.shared.contains(request.definitionId) else { - throw VoltraDynamicLiveActivityError.unknownDefinition(request.definitionId) - } - do { - let source = try await VoltraDynamicLiveActivityBundleSource.loadForApp(definitionId: request.definitionId) - guard VoltraJSRenderer.evaluateLiveActivityBundle(source: source, definitionId: request.definitionId) else { - throw VoltraDynamicLiveActivityError.resourceUnavailable( - NSError(domain: "VoltraDynamicLiveActivity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Dynamic Live Activity bundle could not be evaluated."]) - ) - } - } catch let error as VoltraDynamicLiveActivityError { - throw error - } catch { - throw VoltraDynamicLiveActivityError.resourceUnavailable(error) - } - try VoltraDynamicLiveActivityPayloadValidator.validate( - name: request.name, - deepLinkUrl: request.deepLinkUrl, - props: request.props - ) if request.name.isEmpty == false { try await endActivities(byName: request.name) } - guard try await VoltraDynamicLiveActivityRegistry.shared.create(request) != nil else { - throw VoltraDynamicLiveActivityError.unknownDefinition(request.definitionId) - } - return request.name + return try await dynamicService.create(request) } public func updateDynamicActivity(byName name: String, request: VoltraDynamicLiveActivityUpdateRequest) async throws { guard Self.isSupported() else { throw VoltraLiveActivityError.unsupportedOS } - try VoltraDynamicLiveActivityPayloadValidator.validateContentState(request.props) - if await VoltraDynamicLiveActivityRegistry.shared.update(byName: name, request: request) { return } + if try await dynamicService.update(byName: name, request: request) { return } if findActivity(byName: name) != nil { throw VoltraDynamicLiveActivityError.rendererMismatch } throw VoltraLiveActivityError.notFound } @@ -332,24 +312,7 @@ public class VoltraLiveActivityService { /// WidgetKit render. Legacy activities are deliberately untouched. public func reloadDynamicActivities(definitionIds: [String]?) async { #if DEBUG - let requested = definitionIds.map(Set.init) - let ids = requested ?? Set(dynamicLiveActivityDefinitionIds()) - var refreshed = Set() - for definitionId in ids.sorted() { - guard VoltraDynamicLiveActivityRegistry.shared.contains(definitionId) else { continue } - do { - let source = try await VoltraDynamicLiveActivityBundleSource.loadForApp(definitionId: definitionId) - guard VoltraJSRenderer.evaluateLiveActivityBundle(source: source, definitionId: definitionId) else { - throw VoltraDynamicLiveActivityError.resourceUnavailable( - NSError(domain: "VoltraDynamicLiveActivity", code: -2, userInfo: [NSLocalizedDescriptionKey: "Dynamic Live Activity bundle could not be evaluated."]) - ) - } - refreshed.insert(definitionId) - } catch { - VoltraLogger.activity.error("Failed to refresh Dynamic Live Activity definition '\(definitionId)': \(error)") - } - } - await VoltraDynamicLiveActivityRegistry.shared.reload(definitionIds: refreshed) + await dynamicService.reload(definitionIds: definitionIds) #endif } @@ -377,6 +340,9 @@ public class VoltraLiveActivityService { } let manager = VoltraLiveActivityManager( + onActivityDiscovered: { [chronology] activityId in + chronology.record(activityId) + }, onTokenUpdated: onTokenUpdated, onPushToStartUpdated: onPushToStartUpdated, onStateChanged: { activityName, state in diff --git a/packages/ios-client/ios/app/dynamic-live-activity/VoltraDynamicLiveActivityService.swift b/packages/ios-client/ios/app/dynamic-live-activity/VoltraDynamicLiveActivityService.swift new file mode 100644 index 00000000..ab898ef2 --- /dev/null +++ b/packages/ios-client/ios/app/dynamic-live-activity/VoltraDynamicLiveActivityService.swift @@ -0,0 +1,97 @@ +import ActivityKit +import Foundation + +/// App-process orchestration for the dynamic renderer. The legacy service keeps +/// only thin cross-engine coordination points and delegates feature-specific +/// catalog, bundle, payload, and reload behavior here. +final class VoltraDynamicLiveActivityService { + private let chronology: VoltraLiveActivityChronology + + init(chronology: VoltraLiveActivityChronology = .shared) { + self.chronology = chronology + } + + func isActive(name: String) -> Bool { + activityReferences().contains { $0.name == name } + } + + func activityReferences() -> [VoltraDynamicLiveActivityReference] { + VoltraDynamicLiveActivityRegistry.shared.activities() + } + + func definitionIds() -> [String] { + VoltraDynamicLiveActivityRegistry.shared.definitionIds() + } + + func create(_ request: VoltraDynamicLiveActivityCreateRequest) async throws -> String { + guard VoltraDynamicLiveActivityRegistry.shared.contains(request.definitionId) else { + throw VoltraDynamicLiveActivityError.unknownDefinition(request.definitionId) + } + do { + let source = try await VoltraDynamicLiveActivityBundleSource.loadForApp(definitionId: request.definitionId) + guard VoltraJSRenderer.evaluateLiveActivityBundle(source: source, definitionId: request.definitionId) else { + throw VoltraDynamicLiveActivityError.resourceUnavailable( + NSError( + domain: "VoltraDynamicLiveActivity", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Dynamic Live Activity bundle could not be evaluated."] + ) + ) + } + } catch let error as VoltraDynamicLiveActivityError { + throw error + } catch { + throw VoltraDynamicLiveActivityError.resourceUnavailable(error) + } + try VoltraDynamicLiveActivityPayloadValidator.validate( + name: request.name, + deepLinkUrl: request.deepLinkUrl, + props: request.props + ) + guard let activity = try await VoltraDynamicLiveActivityRegistry.shared.create(request) else { + throw VoltraDynamicLiveActivityError.unknownDefinition(request.definitionId) + } + chronology.record(activity.id) + return request.name + } + + func update(byName name: String, request: VoltraDynamicLiveActivityUpdateRequest) async throws -> Bool { + try VoltraDynamicLiveActivityPayloadValidator.validateContentState(request.props) + return await VoltraDynamicLiveActivityRegistry.shared.update(byName: name, request: request) + } + + func end(byName name: String, dismissalPolicy: ActivityUIDismissalPolicy) async -> Bool { + await VoltraDynamicLiveActivityRegistry.shared.end(byName: name, dismissalPolicy: dismissalPolicy) + } + + func endAll(dismissalPolicy: ActivityUIDismissalPolicy) async { + await VoltraDynamicLiveActivityRegistry.shared.endAll(dismissalPolicy: dismissalPolicy) + } + + func reload(definitionIds: [String]?) async { + #if DEBUG + let requested = definitionIds.map(Set.init) + let ids = requested ?? Set(self.definitionIds()) + var refreshed = Set() + for definitionId in ids.sorted() { + guard VoltraDynamicLiveActivityRegistry.shared.contains(definitionId) else { continue } + do { + let source = try await VoltraDynamicLiveActivityBundleSource.loadForApp(definitionId: definitionId) + guard VoltraJSRenderer.evaluateLiveActivityBundle(source: source, definitionId: definitionId) else { + throw VoltraDynamicLiveActivityError.resourceUnavailable( + NSError( + domain: "VoltraDynamicLiveActivity", + code: -2, + userInfo: [NSLocalizedDescriptionKey: "Dynamic Live Activity bundle could not be evaluated."] + ) + ) + } + refreshed.insert(definitionId) + } catch { + VoltraLogger.activity.error("Failed to refresh Dynamic Live Activity definition '\(definitionId)': \(error)") + } + } + await VoltraDynamicLiveActivityRegistry.shared.reload(definitionIds: refreshed) + #endif + } +} diff --git a/packages/ios-client/ios/app/dynamic-live-activity/VoltraLiveActivityChronology.swift b/packages/ios-client/ios/app/dynamic-live-activity/VoltraLiveActivityChronology.swift new file mode 100644 index 00000000..aca36d3e --- /dev/null +++ b/packages/ios-client/ios/app/dynamic-live-activity/VoltraLiveActivityChronology.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Persists the order in which the app process discovers ActivityKit instances. +/// ActivityKit exposes creation order only within a concrete attributes type, so +/// this ledger supplies a stable cross-engine order for unified query APIs. +final class VoltraLiveActivityChronology: @unchecked Sendable { + static let shared = VoltraLiveActivityChronology() + + private static let storageKey = "Voltra_LiveActivityChronology" + + private let lock = NSLock() + private let defaults: UserDefaults + private var orderedIds: [String] + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + orderedIds = defaults.stringArray(forKey: Self.storageKey) ?? [] + } + + func record(_ activityId: String) { + lock.lock() + defer { lock.unlock() } + guard !orderedIds.contains(activityId) else { return } + orderedIds.append(activityId) + persist() + } + + func order( + _ references: [VoltraDynamicLiveActivityReference] + ) -> [VoltraDynamicLiveActivityReference] { + lock.lock() + defer { lock.unlock() } + + let referencesById = Dictionary(uniqueKeysWithValues: references.map { ($0.id, $0) }) + let nextOrder = VoltraLiveActivityOrder.reconcile(previous: orderedIds, active: references.map(\.id)) + + if nextOrder != orderedIds { + orderedIds = nextOrder + persist() + } + return orderedIds.compactMap { referencesById[$0] } + } + + private func persist() { + defaults.set(orderedIds, forKey: Self.storageKey) + } +} diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityObserver.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityObserver.swift index 1fee3ceb..03ea8e58 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityObserver.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityObserver.swift @@ -5,6 +5,7 @@ import Foundation /// activity streams are type-specific, so the generated catalog invokes this /// observer once for each bundled attributes type. public actor VoltraDynamicLiveActivityObserver { + private let onActivityDiscovered: (@Sendable (String) -> Void)? private let onTokenUpdated: (@Sendable (String, String) -> Void)? private let onStateChanged: (@Sendable (String, String) -> Void)? @@ -13,9 +14,11 @@ public actor VoltraDynamicLiveActivityObserver { private var stateTasks: [String: Task] = [:] public init( + onActivityDiscovered: (@Sendable (String) -> Void)? = nil, onTokenUpdated: (@Sendable (String, String) -> Void)? = nil, onStateChanged: (@Sendable (String, String) -> Void)? = nil ) { + self.onActivityDiscovered = onActivityDiscovered self.onTokenUpdated = onTokenUpdated self.onStateChanged = onStateChanged } @@ -56,6 +59,7 @@ public actor VoltraDynamicLiveActivityObserver { ) { let key = "\(definitionId):\(activity.id)" let name = activity.attributes.name + onActivityDiscovered?(activity.id) if let onTokenUpdated, tokenTasks[key] == nil { tokenTasks[key] = Task { [weak self] in diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraLiveActivityOrder.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraLiveActivityOrder.swift new file mode 100644 index 00000000..3a8d10ab --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraLiveActivityOrder.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Reconciles a persisted cross-engine creation order with ActivityKit's +/// currently active IDs, pruning ended activities and appending new discoveries. +public enum VoltraLiveActivityOrder { + public static func reconcile(previous: [String], active: [String]) -> [String] { + let activeIds = Set(active) + var seen = Set() + var result: [String] = [] + + for id in previous where activeIds.contains(id) && seen.insert(id).inserted { + result.append(id) + } + for id in active where seen.insert(id).inserted { + result.append(id) + } + return result + } +} From 2b7b2f35d26e46588c5b7bc829847c3a1ffc7118 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 10:53:23 +0200 Subject: [PATCH 21/24] fix: isolate naming helper from core runtime --- .../android-client/expo-plugin/tsconfig.typecheck.json | 1 + packages/core/package.json | 6 ++++++ packages/core/src/index.ts | 1 - packages/expo-plugin/jest.config.js | 2 +- .../expo-plugin/src/dynamic-live-activity.node.test.ts | 4 ++-- packages/expo-plugin/src/dynamic-live-activity.ts | 2 +- packages/expo-plugin/tsconfig.typecheck.json | 7 ++++++- packages/ios-client/expo-plugin/jest.config.js | 2 +- packages/ios-client/expo-plugin/tsconfig.typecheck.json | 1 + packages/ios-client/tsconfig.typecheck.json | 1 + packages/ios/src/live-activity/dynamic.ts | 2 +- packages/ios/tsconfig.base.json | 3 ++- packages/ios/tsconfig.typecheck.json | 1 + packages/metro/tsconfig.typecheck.json | 1 + 14 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/android-client/expo-plugin/tsconfig.typecheck.json b/packages/android-client/expo-plugin/tsconfig.typecheck.json index 0b484180..ae17feae 100644 --- a/packages/android-client/expo-plugin/tsconfig.typecheck.json +++ b/packages/android-client/expo-plugin/tsconfig.typecheck.json @@ -6,6 +6,7 @@ "baseUrl": "../../..", "paths": { "@use-voltra/compiler": ["packages/compiler/src/index.ts"], + "@use-voltra/core/dynamic-live-activity": ["packages/core/src/dynamic-live-activity.ts"], "@use-voltra/expo-plugin": ["packages/expo-plugin/src/index.ts"] } }, diff --git a/packages/core/package.json b/packages/core/package.json index 9c6594b2..1ff0b3cd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,6 +12,12 @@ "import": "./build/esm/index.js", "default": "./build/esm/index.js" }, + "./dynamic-live-activity": { + "types": "./build/types/dynamic-live-activity.d.ts", + "require": "./build/cjs/dynamic-live-activity.js", + "import": "./build/esm/dynamic-live-activity.js", + "default": "./build/esm/dynamic-live-activity.js" + }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b110948e..442aa610 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,4 @@ export * from './jsx/createVoltraComponent.js' -export * from './dynamic-live-activity.js' export * from './payload.js' export * from './payload/short-names.js' export * from './renderer/index.js' diff --git a/packages/expo-plugin/jest.config.js b/packages/expo-plugin/jest.config.js index a7cb36f2..5192c1f5 100644 --- a/packages/expo-plugin/jest.config.js +++ b/packages/expo-plugin/jest.config.js @@ -5,7 +5,7 @@ module.exports = { modulePathIgnorePatterns: ['/build'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', - '^@use-voltra/core$': '/../core/src/index.ts', + '^@use-voltra/core/dynamic-live-activity$': '/../core/src/dynamic-live-activity.ts', }, transform: { '^.+\\.tsx?$': [ diff --git a/packages/expo-plugin/src/dynamic-live-activity.node.test.ts b/packages/expo-plugin/src/dynamic-live-activity.node.test.ts index caa41b0c..f2fca47b 100644 --- a/packages/expo-plugin/src/dynamic-live-activity.node.test.ts +++ b/packages/expo-plugin/src/dynamic-live-activity.node.test.ts @@ -1,5 +1,5 @@ -import { getDynamicLiveActivityAttributesType as getCoreAttributesType } from '../../core/src/dynamic-live-activity' import { getDynamicLiveActivityAttributesType as getIOSAttributesType } from '../../ios/src/live-activity/dynamic' +import { getDynamicLiveActivityAttributesType as getCanonicalAttributesType } from '../../core/src/dynamic-live-activity' import { getDynamicLiveActivityAttributesType as getExpoPluginAttributesType } from './dynamic-live-activity' @@ -11,7 +11,7 @@ describe('Dynamic Live Activity attributes type naming', () => { ['_driver_arrived_', 'VoltraDriverArrivedLiveActivityAttributes'], ['alreadyCamel', 'VoltraAlreadyCamelLiveActivityAttributes'], ])('keeps core, Expo generation, and the iOS public helper aligned for %p', (definitionId, expected) => { - expect(getCoreAttributesType(definitionId)).toBe(expected) + expect(getCanonicalAttributesType(definitionId)).toBe(expected) expect(getExpoPluginAttributesType(definitionId)).toBe(expected) expect(getIOSAttributesType(definitionId)).toBe(expected) }) diff --git a/packages/expo-plugin/src/dynamic-live-activity.ts b/packages/expo-plugin/src/dynamic-live-activity.ts index 3536ab72..92c2436b 100644 --- a/packages/expo-plugin/src/dynamic-live-activity.ts +++ b/packages/expo-plugin/src/dynamic-live-activity.ts @@ -1 +1 @@ -export { getDynamicLiveActivityAttributesType } from '@use-voltra/core' +export { getDynamicLiveActivityAttributesType } from '@use-voltra/core/dynamic-live-activity' diff --git a/packages/expo-plugin/tsconfig.typecheck.json b/packages/expo-plugin/tsconfig.typecheck.json index e1b6a74c..6ad020ad 100644 --- a/packages/expo-plugin/tsconfig.typecheck.json +++ b/packages/expo-plugin/tsconfig.typecheck.json @@ -1,6 +1,11 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "module": "ES2020" + "module": "ES2020", + "rootDir": "../..", + "baseUrl": "../..", + "paths": { + "@use-voltra/core/dynamic-live-activity": ["packages/core/src/dynamic-live-activity.ts"] + } } } diff --git a/packages/ios-client/expo-plugin/jest.config.js b/packages/ios-client/expo-plugin/jest.config.js index cbbd2d59..f5a51828 100644 --- a/packages/ios-client/expo-plugin/jest.config.js +++ b/packages/ios-client/expo-plugin/jest.config.js @@ -6,7 +6,7 @@ module.exports = { moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', '^@use-voltra/compiler$': '/../../compiler/src/index.ts', - '^@use-voltra/core$': '/../../core/src/index.ts', + '^@use-voltra/core/dynamic-live-activity$': '/../../core/src/dynamic-live-activity.ts', '^@use-voltra/expo-plugin$': '/../../expo-plugin/src/index.ts', '^@use-voltra/expo-plugin/(.*)$': '/../../expo-plugin/src/$1', '^@use-voltra/metro/scanner$': '/../../metro/src/scanner.ts', diff --git a/packages/ios-client/expo-plugin/tsconfig.typecheck.json b/packages/ios-client/expo-plugin/tsconfig.typecheck.json index eb1a3623..4417c3ab 100644 --- a/packages/ios-client/expo-plugin/tsconfig.typecheck.json +++ b/packages/ios-client/expo-plugin/tsconfig.typecheck.json @@ -6,6 +6,7 @@ "baseUrl": "../../..", "paths": { "@use-voltra/compiler": ["packages/compiler/src/index.ts"], + "@use-voltra/core/dynamic-live-activity": ["packages/core/src/dynamic-live-activity.ts"], "@use-voltra/expo-plugin": ["packages/expo-plugin/src/index.ts"], "@use-voltra/metro/scanner": ["packages/metro/src/scanner.ts"] } diff --git a/packages/ios-client/tsconfig.typecheck.json b/packages/ios-client/tsconfig.typecheck.json index 8526aab6..5e2d4a8d 100644 --- a/packages/ios-client/tsconfig.typecheck.json +++ b/packages/ios-client/tsconfig.typecheck.json @@ -9,6 +9,7 @@ "@use-voltra/android/client": ["packages/android-client/src/index.ts"], "@use-voltra/android-server": ["packages/android-server/src/index.ts"], "@use-voltra/core": ["packages/core/src/index.ts"], + "@use-voltra/core/dynamic-live-activity": ["packages/core/src/dynamic-live-activity.ts"], "@use-voltra/ios": ["packages/ios/src/index.ts"], "@use-voltra/ios-client/react-native": ["packages/ios-client/src/react-native/index.ts"], "@use-voltra/ios/client": ["packages/ios-client/src/index.ts"], diff --git a/packages/ios/src/live-activity/dynamic.ts b/packages/ios/src/live-activity/dynamic.ts index 887fbaa8..f68153c3 100644 --- a/packages/ios/src/live-activity/dynamic.ts +++ b/packages/ios/src/live-activity/dynamic.ts @@ -21,4 +21,4 @@ export interface DynamicLiveActivityContentState { props: DynamicLiveActivityProps } -export { getDynamicLiveActivityAttributesType } from '@use-voltra/core' +export { getDynamicLiveActivityAttributesType } from '@use-voltra/core/dynamic-live-activity' diff --git a/packages/ios/tsconfig.base.json b/packages/ios/tsconfig.base.json index 4833c865..08a4d279 100644 --- a/packages/ios/tsconfig.base.json +++ b/packages/ios/tsconfig.base.json @@ -5,7 +5,8 @@ "baseUrl": ".", "moduleResolution": "node", "paths": { - "@use-voltra/core": ["../core/build/types/index.d.ts"] + "@use-voltra/core": ["../core/build/types/index.d.ts"], + "@use-voltra/core/dynamic-live-activity": ["../core/build/types/dynamic-live-activity.d.ts"] } }, "include": ["./src"], diff --git a/packages/ios/tsconfig.typecheck.json b/packages/ios/tsconfig.typecheck.json index fd44b930..42aa5ab4 100644 --- a/packages/ios/tsconfig.typecheck.json +++ b/packages/ios/tsconfig.typecheck.json @@ -11,6 +11,7 @@ "@use-voltra/android/server": ["packages/android/src/server.ts"], "@use-voltra/android-server": ["packages/android-server/src/index.ts"], "@use-voltra/core": ["packages/core/src/index.ts"], + "@use-voltra/core/dynamic-live-activity": ["packages/core/src/dynamic-live-activity.ts"], "@use-voltra/ios": ["packages/ios/src/index.ts"], "@use-voltra/ios-client": ["packages/ios-client/src/index.ts"], "@use-voltra/ios/client": ["packages/ios-client/src/index.ts"], diff --git a/packages/metro/tsconfig.typecheck.json b/packages/metro/tsconfig.typecheck.json index 8d869f11..1e1be803 100644 --- a/packages/metro/tsconfig.typecheck.json +++ b/packages/metro/tsconfig.typecheck.json @@ -12,6 +12,7 @@ "@use-voltra/android-server": ["packages/android-server/src/index.ts"], "@use-voltra/compiler": ["packages/compiler/src/index.ts"], "@use-voltra/core": ["packages/core/src/index.ts"], + "@use-voltra/core/dynamic-live-activity": ["packages/core/src/dynamic-live-activity.ts"], "@use-voltra/expo-plugin": ["packages/expo-plugin/src/index.ts"], "@use-voltra/ios": ["packages/ios/src/index.ts"], "@use-voltra/ios-client": ["packages/ios-client/src/index.ts"], From 5c91b253382ac5156f9363c60f054bde2e7e19ea Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 10:55:01 +0200 Subject: [PATCH 22/24] fix: resolve naming contract in server checks --- packages/android-server/tsconfig.typecheck.json | 1 + packages/ios-server/tsconfig.typecheck.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/android-server/tsconfig.typecheck.json b/packages/android-server/tsconfig.typecheck.json index ede85286..6fa0fc63 100644 --- a/packages/android-server/tsconfig.typecheck.json +++ b/packages/android-server/tsconfig.typecheck.json @@ -12,6 +12,7 @@ "@use-voltra/android/server": ["packages/android/src/server.ts"], "@use-voltra/android-server": ["packages/android-server/src/index.ts"], "@use-voltra/core": ["packages/core/src/index.ts"], + "@use-voltra/core/dynamic-live-activity": ["packages/core/src/dynamic-live-activity.ts"], "@use-voltra/ios": ["packages/ios/src/index.ts"], "@use-voltra/ios-client": ["packages/ios-client/src/index.ts"], "@use-voltra/ios/client": ["packages/ios-client/src/index.ts"], diff --git a/packages/ios-server/tsconfig.typecheck.json b/packages/ios-server/tsconfig.typecheck.json index fd44b930..42aa5ab4 100644 --- a/packages/ios-server/tsconfig.typecheck.json +++ b/packages/ios-server/tsconfig.typecheck.json @@ -11,6 +11,7 @@ "@use-voltra/android/server": ["packages/android/src/server.ts"], "@use-voltra/android-server": ["packages/android-server/src/index.ts"], "@use-voltra/core": ["packages/core/src/index.ts"], + "@use-voltra/core/dynamic-live-activity": ["packages/core/src/dynamic-live-activity.ts"], "@use-voltra/ios": ["packages/ios/src/index.ts"], "@use-voltra/ios-client": ["packages/ios-client/src/index.ts"], "@use-voltra/ios/client": ["packages/ios-client/src/index.ts"], From bcb01b5282fb48473e0c697d80c6969043eaf274 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 10:55:57 +0200 Subject: [PATCH 23/24] fix: resolve naming contract in plugin builds --- packages/expo-plugin/tsconfig.base.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/expo-plugin/tsconfig.base.json b/packages/expo-plugin/tsconfig.base.json index 6bb57ecd..010da112 100644 --- a/packages/expo-plugin/tsconfig.base.json +++ b/packages/expo-plugin/tsconfig.base.json @@ -3,7 +3,11 @@ "target": "ES2020", "lib": ["ES2020"], "rootDir": "./src", + "baseUrl": ".", "moduleResolution": "node", + "paths": { + "@use-voltra/core/dynamic-live-activity": ["../core/build/types/dynamic-live-activity.d.ts"] + }, "jsx": "react-jsx", "strict": true, "esModuleInterop": true, From 0d160c78869b2da9b286857c160ac83d52d463b8 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 4 Aug 2026 11:32:07 +0200 Subject: [PATCH 24/24] fix(ios): harden dynamic live activity runtime --- .../fix-dynamic-live-activity-runtime.md | 7 ++ example/app/_layout.tsx | 1 + .../ios-widget/dynamic-live-activity/swift.ts | 1 - .../src/ios-widget/files/swift.node.test.ts | 1 - .../ios-widget/xcode/buildPhases.node.test.ts | 16 +++++ .../src/ios-widget/xcode/buildPhases.ts | 56 ++++++++++++--- .../expo-plugin/src/ios-widget/xcode/index.ts | 4 +- packages/ios-client/ios/app/NativeVoltra.mm | 7 +- .../ios/app/VoltraLiveActivityService.swift | 6 +- .../ios-client/ios/app/VoltraModule.swift | 4 +- .../ios-client/ios/app/VoltraModuleImpl.swift | 4 +- .../VoltraLiveActivityChronology.swift | 49 +++++++++---- .../ios/shared/VoltraEventBus.swift | 16 ++--- .../VoltraDynamicLiveActivityRenderer.swift | 72 +++++++++++++++++-- .../VoltraDynamicLiveActivityTypes.swift | 2 - packages/ios-client/src/events.ts | 24 ++++++- .../ios-client/src/native/NativeVoltra.ts | 1 + .../renderFailureEvents.node.test.ts | 37 ++++++++-- packages/metro/src/bundleWidgets.ts | 24 +++++-- 19 files changed, 270 insertions(+), 62 deletions(-) create mode 100644 .changeset/fix-dynamic-live-activity-runtime.md diff --git a/.changeset/fix-dynamic-live-activity-runtime.md b/.changeset/fix-dynamic-live-activity-runtime.md new file mode 100644 index 00000000..818e50d3 --- /dev/null +++ b/.changeset/fix-dynamic-live-activity-runtime.md @@ -0,0 +1,7 @@ +--- +'@use-voltra/ios-client': patch +'@use-voltra/metro': patch +--- + +Fix Dynamic Live Activity diagnostic delivery, release-resource isolation, and +refresh rendering consistency. diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index b242516c..eed912d3 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -7,6 +7,7 @@ import { } from '@use-voltra/ios-client' import { enableWidgetHotReload as enableAndroidWidgetHotReload } from '@use-voltra/android-client' import '@use-voltra/widget-hot-reload' +import '@use-voltra/live-activity-hot-reload' import { useVoltraEvents } from '~/hooks/useVoltraEvents' import { useServerDrivenWidgetToken } from '~/hooks/useServerDrivenWidgetToken' diff --git a/packages/ios-client/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts b/packages/ios-client/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts index 9899aefb..3beeffea 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts @@ -122,7 +122,6 @@ function generateDynamicLiveActivitySwift(liveActivity: IOSDynamicLiveActivityCo extension ${attributesType}: VoltraDynamicLiveActivityDefinition { public static let definitionId = "${definitionId}" - public static let attributesTypeName = "${attributesType}" } ` diff --git a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts index 025e6cb4..63439d50 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts @@ -95,7 +95,6 @@ describe('Dynamic Live Activity Swift generation', () => { expect(types).toContain('public typealias ContentState = VoltraDynamicLiveActivityContentState') expect(types).toContain('public let name: String') expect(types).toContain('public let deepLinkUrl: String?') - expect(types).toContain('public static let attributesTypeName = "VoltraOrderFinishedLiveActivityAttributes"') expect(types).toContain('import VoltraRuntime') expect(types).not.toContain('import VoltraWidget') expect(types).toContain('@objc(VoltraGeneratedDynamicLiveActivityRegistration)') diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.node.test.ts index 9f68bd53..390a8c5d 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.node.test.ts @@ -60,6 +60,22 @@ describe('ensureWidgetBundleScriptPhase', () => { expect(project.hash.project.objects.PBXNativeTarget[TARGET_UUID].buildPhases).toHaveLength(1) }) + it('migrates the legacy phase name without leaving a duplicate phase behind', () => { + const project = makeProject() + const legacyUuid = 'C'.repeat(24) + project.hash.project.objects.PBXShellScriptBuildPhase = { + [legacyUuid]: { name: '"Bundle Voltra Dynamic Widgets"', shellScript: 'legacy' }, + } + project.hash.project.objects.PBXNativeTarget[TARGET_UUID].buildPhases = [{ value: legacyUuid }] + + ensureWidgetBundleScriptPhase(project, TARGET_UUID, 'live-activities') + + const phases = shellPhaseObjects(project) + expect(phases).toHaveLength(1) + expect(phases[0].name).toBe('"Bundle Voltra Dynamic Content"') + expect(phases[0].shellScript).toContain('--content live-activities') + }) + it('does nothing when the target is absent', () => { const project = makeProject() ensureWidgetBundleScriptPhase(project, 'B'.repeat(24)) 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 3014338c..8802598f 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 @@ -7,6 +7,7 @@ import { ensureWidgetFileReference, normalizeRef } from './fileReferences' const pbxFile = require('xcode/lib/pbxFile') const WIDGET_BUNDLE_PHASE_NAME = 'Bundle Voltra Dynamic Content' +const LEGACY_WIDGET_BUNDLE_PHASE_NAME = 'Bundle Voltra Dynamic Widgets' // Release-only build phase that bakes each Dynamic Widget's production JS bundle into the // extension's resources. Debug builds fetch from Metro (and hot-reload), so this no-ops there. Runs @@ -14,7 +15,8 @@ const WIDGET_BUNDLE_PHASE_NAME = 'Bundle Voltra Dynamic Content' // voltra-widget-.bundle lands in the .appex (Bundle.main) where the runtime's release loader // reads it. SRCROOT is the ios/ dir; the project root is one level up, matching how Expo's main // "Bundle React Native code and images" phase resolves things. -const WIDGET_BUNDLE_SHELL_SCRIPT = `if [[ "$CONFIGURATION" == *Debug* ]]; then +function bundleShellScript(content: 'all' | 'live-activities'): string { + return `if [[ "$CONFIGURATION" == *Debug* ]]; then echo "Voltra: Debug build — Dynamic Widgets load from Metro, skipping bundling" exit 0 fi @@ -48,6 +50,7 @@ try { console.error(error && error.message ? error.message : String(error)) process.exit(1) } + NODE )" if [[ -z "$BUNDLER" ]]; then @@ -56,14 +59,23 @@ if [[ -z "$BUNDLER" ]]; then fi echo "Voltra: widget bundler resolved to $BUNDLER" -"$NODE_BINARY" "$BUNDLER" --out-dir "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" --platform ios --project-root "$PROJECT_ROOT" +"$NODE_BINARY" "$BUNDLER" --out-dir "$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH" --platform ios --project-root "$PROJECT_ROOT" --content ${content} ` +} + +function pbxQuotedShellScript(script: string): string { + return `"${script.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"` +} /** * Adds (idempotently) the release-only shell-script phase that bakes Dynamic Widget * bundles into the extension. Safe to call on every prebuild; only added when absent. */ -export function ensureWidgetBundleScriptPhase(xcodeProject: XcodeProject, targetUuid: string): void { +export function ensureWidgetBundleScriptPhase( + xcodeProject: XcodeProject, + targetUuid: string, + content: 'all' | 'live-activities' = 'all' +): void { const nativeTargets = xcodeProject.pbxNativeTargetSection() const target = nativeTargets[targetUuid] if (!target) { @@ -75,15 +87,37 @@ export function ensureWidgetBundleScriptPhase(xcodeProject: XcodeProject, target const shellPhases = xcodeProject.hash.project.objects.PBXShellScriptBuildPhase || {} const quotedName = `"${WIDGET_BUNDLE_PHASE_NAME}"` - const alreadyPresent = target.buildPhases.some((entry: any) => shellPhases[entry.value]?.name === quotedName) - if (alreadyPresent) { - return - } - - xcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', WIDGET_BUNDLE_PHASE_NAME, targetUuid, { - shellPath: '/bin/sh', - shellScript: WIDGET_BUNDLE_SHELL_SCRIPT, + const quotedLegacyName = `"${LEGACY_WIDGET_BUNDLE_PHASE_NAME}"` + const matchingEntries = target.buildPhases.filter((entry: any) => { + const name = shellPhases[entry.value]?.name + return name === quotedName || name === quotedLegacyName }) + let phase: any | undefined + if (matchingEntries.length > 0) { + const retained = matchingEntries[0] + phase = shellPhases[retained.value] + phase.name = quotedName + target.buildPhases = target.buildPhases.filter((entry: any) => { + if (entry === retained || !matchingEntries.includes(entry)) return true + delete shellPhases[entry.value] + delete shellPhases[`${entry.value}_comment`] + return false + }) + } else { + xcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', WIDGET_BUNDLE_PHASE_NAME, targetUuid, { + shellPath: '/bin/sh', + shellScript: bundleShellScript(content), + }) + phase = Object.values(xcodeProject.hash.project.objects.PBXShellScriptBuildPhase || {}).find( + (candidate: any) => candidate?.name === quotedName + ) + } + if (phase) { + // xcode's serializer expects an already PBX-quoted value when an existing + // phase is reconciled directly (addBuildPhase performs this for new ones). + phase.shellScript = pbxQuotedShellScript(bundleShellScript(content)) + phase.alwaysOutOfDate = 1 + } // The phase intentionally re-bakes on every release build (it can't statically enumerate every // widget source as an input). Mark it always-out-of-date so Xcode doesn't warn about the missing diff --git a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts index 7fdae127..3bff1c6b 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/xcode/index.ts @@ -118,10 +118,12 @@ export function applyXcodeChanges( if (hasClientRenderedWidgets || (props.liveActivities?.length ?? 0) > 0) { ensureWidgetBundleScriptPhase(xcodeProject, targetUuid) + } + if ((props.liveActivities?.length ?? 0) > 0) { // Local Dynamic Live Activity starts preflight the baked definition from // the app process, while WidgetKit renders from the extension process. // Bake the same manifest into both products. - ensureWidgetBundleScriptPhase(xcodeProject, mainTargetUuid) + ensureWidgetBundleScriptPhase(xcodeProject, mainTargetUuid, 'live-activities') } ensureTargetAttributes(xcodeProject, targetUuid) diff --git a/packages/ios-client/ios/app/NativeVoltra.mm b/packages/ios-client/ios/app/NativeVoltra.mm index a8e3206c..42c7864c 100644 --- a/packages/ios-client/ios/app/NativeVoltra.mm +++ b/packages/ios-client/ios/app/NativeVoltra.mm @@ -107,7 +107,12 @@ - (void)applicationWillEnterForeground - (void)drainDynamicLiveActivityRenderFailures { - [self.module requestDynamicLiveActivityRenderFailureDrain]; + [self.module drainDynamicLiveActivityRenderFailures]; +} + +- (void)setDynamicLiveActivityRenderFailureListenerActive:(BOOL)active +{ + [self.module setDynamicLiveActivityRenderFailureListenerActive:active]; } - (UIView *)reactRootViewInView:(UIView *)view diff --git a/packages/ios-client/ios/app/VoltraLiveActivityService.swift b/packages/ios-client/ios/app/VoltraLiveActivityService.swift index b00baf85..79afcff3 100644 --- a/packages/ios-client/ios/app/VoltraLiveActivityService.swift +++ b/packages/ios-client/ios/app/VoltraLiveActivityService.swift @@ -138,7 +138,11 @@ public class VoltraLiveActivityService { } public func latestActivityId() -> String? { - getAllActivityReferences().last?.id + guard Self.isSupported() else { return nil } + let legacy = getAllActivities().map { + VoltraDynamicLiveActivityReference(id: $0.id, name: $0.attributes.name, definitionId: "legacy") + } + return chronology.latest(legacy + dynamicService.activityReferences())?.id } /// The installed capability list is generated during prebuild and does not diff --git a/packages/ios-client/ios/app/VoltraModule.swift b/packages/ios-client/ios/app/VoltraModule.swift index 8a15eefd..c758f95f 100644 --- a/packages/ios-client/ios/app/VoltraModule.swift +++ b/packages/ios-client/ios/app/VoltraModule.swift @@ -176,8 +176,8 @@ public enum VoltraErrors: Error, CustomNSError { impl.drainDynamicLiveActivityRenderFailures() } - @objc public func requestDynamicLiveActivityRenderFailureDrain() { - impl.requestDynamicLiveActivityRenderFailureDrain() + @objc public func setDynamicLiveActivityRenderFailureListenerActive(_ active: Bool) { + impl.setDynamicLiveActivityRenderFailureListenerActive(active) } // MARK: - Images diff --git a/packages/ios-client/ios/app/VoltraModuleImpl.swift b/packages/ios-client/ios/app/VoltraModuleImpl.swift index 1a45150e..eb3b512d 100644 --- a/packages/ios-client/ios/app/VoltraModuleImpl.swift +++ b/packages/ios-client/ios/app/VoltraModuleImpl.swift @@ -93,8 +93,8 @@ public class VoltraModuleImpl { VoltraEventBus.shared.drainDynamicLiveActivityRenderFailures() } - func requestDynamicLiveActivityRenderFailureDrain() { - VoltraEventBus.shared.requestDynamicLiveActivityRenderFailureDrain() + func setDynamicLiveActivityRenderFailureListenerActive(_ active: Bool) { + VoltraEventBus.shared.setDynamicLiveActivityRenderFailureListenerActive(active) } var pushNotificationsEnabled: Bool { diff --git a/packages/ios-client/ios/app/dynamic-live-activity/VoltraLiveActivityChronology.swift b/packages/ios-client/ios/app/dynamic-live-activity/VoltraLiveActivityChronology.swift index aca36d3e..1ce70062 100644 --- a/packages/ios-client/ios/app/dynamic-live-activity/VoltraLiveActivityChronology.swift +++ b/packages/ios-client/ios/app/dynamic-live-activity/VoltraLiveActivityChronology.swift @@ -1,8 +1,9 @@ import Foundation -/// Persists the order in which the app process discovers ActivityKit instances. -/// ActivityKit exposes creation order only within a concrete attributes type, so -/// this ledger supplies a stable cross-engine order for unified query APIs. +/// Persists creation-time observations made by this app process. ActivityKit +/// does not expose a cross-attributes-type creation timestamp, so an instance +/// first encountered after a process restart must never be assigned an order +/// merely because a catalog happened to enumerate it first. final class VoltraLiveActivityChronology: @unchecked Sendable { static let shared = VoltraLiveActivityChronology() @@ -10,18 +11,24 @@ final class VoltraLiveActivityChronology: @unchecked Sendable { private let lock = NSLock() private let defaults: UserDefaults - private var orderedIds: [String] + private var timestamps: [String: TimeInterval] init(defaults: UserDefaults = .standard) { self.defaults = defaults - orderedIds = defaults.stringArray(forKey: Self.storageKey) ?? [] + if let stored = defaults.dictionary(forKey: Self.storageKey) as? [String: TimeInterval] { + timestamps = stored + } else { + // Migrate the previous observation-order ledger without reordering it. + let legacy = defaults.stringArray(forKey: Self.storageKey) ?? [] + timestamps = Dictionary(uniqueKeysWithValues: legacy.enumerated().map { index, id in (id, Double(index)) }) + } } func record(_ activityId: String) { lock.lock() defer { lock.unlock() } - guard !orderedIds.contains(activityId) else { return } - orderedIds.append(activityId) + guard timestamps[activityId] == nil else { return } + timestamps[activityId] = Date().timeIntervalSince1970 persist() } @@ -31,17 +38,31 @@ final class VoltraLiveActivityChronology: @unchecked Sendable { lock.lock() defer { lock.unlock() } - let referencesById = Dictionary(uniqueKeysWithValues: references.map { ($0.id, $0) }) - let nextOrder = VoltraLiveActivityOrder.reconcile(previous: orderedIds, active: references.map(\.id)) - - if nextOrder != orderedIds { - orderedIds = nextOrder + let activeIds = Set(references.map(\.id)) + let nextTimestamps = timestamps.filter { activeIds.contains($0.key) } + if nextTimestamps != timestamps { + timestamps = nextTimestamps persist() } - return orderedIds.compactMap { referencesById[$0] } + return references.sorted { lhs, rhs in + switch (timestamps[lhs.id], timestamps[rhs.id]) { + case let (left?, right?): return left < right + case (_?, nil): return true + case (nil, _?): return false + case (nil, nil): return lhs.id < rhs.id + } + } + } + + func latest(_ references: [VoltraDynamicLiveActivityReference]) -> VoltraDynamicLiveActivityReference? { + lock.lock() + defer { lock.unlock() } + return references.compactMap { reference in + timestamps[reference.id].map { (reference, $0) } + }.max { $0.1 < $1.1 }?.0 } private func persist() { - defaults.set(orderedIds, forKey: Self.storageKey) + defaults.set(timestamps, forKey: Self.storageKey) } } diff --git a/packages/ios-client/ios/shared/VoltraEventBus.swift b/packages/ios-client/ios/shared/VoltraEventBus.swift index f1011973..6041a3dd 100644 --- a/packages/ios-client/ios/shared/VoltraEventBus.swift +++ b/packages/ios-client/ios/shared/VoltraEventBus.swift @@ -10,7 +10,7 @@ public class VoltraEventBus { private var observer: NSObjectProtocol? private var renderFailureObserver: UUID? private var handler: ((String, [String: Any]) -> Void)? - private var isRenderFailureListenerReady = false + private var renderFailureListenerCount = 0 private let lock = NSLock() private init() {} @@ -78,13 +78,13 @@ public class VoltraEventBus { VoltraLogger.event.info("Replayed \(persistedEvents.count) persisted events") } - /// Called only after JavaScript has installed the dedicated failure listener. - /// Until then notifier and foreground callbacks leave persisted failures intact. - public func requestDynamicLiveActivityRenderFailureDrain() { + /// JS owns the dedicated emitter subscription count. Persisted failures must + /// remain queued whenever there is no relevant JavaScript listener. + public func setDynamicLiveActivityRenderFailureListenerActive(_ active: Bool) { lock.lock() - isRenderFailureListenerReady = true + renderFailureListenerCount = active ? 1 : 0 lock.unlock() - drainDynamicLiveActivityRenderFailures() + if active { drainDynamicLiveActivityRenderFailures() } } /// Flush only the dedicated Dynamic Live Activity diagnostic queue. This @@ -92,7 +92,7 @@ public class VoltraEventBus { public func drainDynamicLiveActivityRenderFailures() { lock.lock() let handler = handler - let isReady = isRenderFailureListenerReady + let isReady = renderFailureListenerCount > 0 lock.unlock() guard isReady, let handler else { return } @@ -119,7 +119,7 @@ public class VoltraEventBus { self.renderFailureObserver = nil } handler = nil - isRenderFailureListenerReady = false + renderFailureListenerCount = 0 } deinit { diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift index e1436ed6..4e12f2e3 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift @@ -21,8 +21,7 @@ public enum VoltraDynamicLiveActivityRenderer { definitionId: String, context: ActivityViewContext ) -> DynamicIsland { - let fallbackContent = resolve(definitionId: definitionId, context: context, activityFamily: nil) - let island = DynamicIsland { + DynamicIsland { DynamicIslandExpandedRegion(.leading) { VoltraDynamicLiveActivityDynamicIslandRegionView( definitionId: definitionId, @@ -71,10 +70,9 @@ public enum VoltraDynamicLiveActivityRenderer { ) } - if let keylineTint = fallbackContent.payload?.keylineTint, let color = JSColorParser.parse(keylineTint) { - return island.keylineTint(color) - } - return island + // DynamicIsland itself is not a View and cannot read SwiftUI Environment. + // Do not execute a definition against fabricated values solely to obtain a + // keyline tint; its region views below read the actual environment. } fileprivate static func resolve( @@ -93,6 +91,38 @@ public enum VoltraDynamicLiveActivityRenderer { logFailure(definitionId: definitionId, activityName: context.attributes.name, message: "Could not encode content-state props") return .empty } + let cacheKey = VoltraDynamicLiveActivityRenderCache.Key( + activityId: context.activityID, + definitionId: definitionId, + props: propsJSON, + activityFamily: activityFamily, + colorScheme: String(describing: colorScheme ?? .light), + locale: locale.identifier, + widgetRenderingMode: String(describing: widgetRenderingMode), + isStale: context.isStale + ) + return VoltraDynamicLiveActivityRenderCache.shared.resolve(cacheKey) { + resolveUncached( + definitionId: definitionId, + context: context, + propsJSON: propsJSON, + activityFamily: activityFamily, + colorScheme: colorScheme, + locale: locale, + widgetRenderingMode: widgetRenderingMode + ) + } + } + + private static func resolveUncached( + definitionId: String, + context: ActivityViewContext, + propsJSON: String, + activityFamily: String?, + colorScheme: ColorScheme?, + locale: Locale, + widgetRenderingMode: WidgetRenderingMode + ) -> VoltraDynamicLiveActivityResolvedContent { let environmentJSON = VoltraDynamicLiveActivityEnvironmentBuilder.build( date: Date(), colorScheme: colorScheme, @@ -255,6 +285,36 @@ private struct VoltraDynamicLiveActivityResolvedContent { } } +/// WidgetKit may independently ask each Dynamic Island region to render. Cache +/// the complete variants shape per ActivityKit state/environment so all regions +/// share one JS evaluation and one failure diagnostic. +private final class VoltraDynamicLiveActivityRenderCache: @unchecked Sendable { + struct Key: Hashable { + let activityId: String + let definitionId: String + let props: String + let activityFamily: String? + let colorScheme: String + let locale: String + let widgetRenderingMode: String + let isStale: Bool + } + + static let shared = VoltraDynamicLiveActivityRenderCache() + private let lock = NSLock() + private var values: [Key: VoltraDynamicLiveActivityResolvedContent] = [:] + + func resolve(_ key: Key, render: () -> VoltraDynamicLiveActivityResolvedContent) -> VoltraDynamicLiveActivityResolvedContent { + lock.lock() + defer { lock.unlock() } + if let existing = values[key] { return existing } + let resolved = render() + if values.count >= 100 { values.removeAll(keepingCapacity: true) } + values[key] = resolved + return resolved + } +} + private enum VoltraDynamicLiveActivityEnvironmentBuilder { static func build( date: Date, diff --git a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift index 6174188a..d6590e8d 100644 --- a/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift @@ -80,8 +80,6 @@ public enum VoltraDynamicLiveActivityError: Error { /// Metadata and static attributes supplied by each generated definition. public protocol VoltraDynamicLiveActivityDefinition: ActivityAttributes where ContentState == VoltraDynamicLiveActivityContentState { static var definitionId: String { get } - static var attributesTypeName: String { get } - var name: String { get } var deepLinkUrl: String? { get } init(name: String, deepLinkUrl: String?) diff --git a/packages/ios-client/src/events.ts b/packages/ios-client/src/events.ts index 49523336..e33ef0d6 100644 --- a/packages/ios-client/src/events.ts +++ b/packages/ios-client/src/events.ts @@ -41,6 +41,11 @@ const noopSubscription: EventSubscription = { remove: () => {}, } +// Codegen EventEmitter subscriptions do not tell native code when their last +// JavaScript listener goes away. Keep this count at the public API boundary so +// the App Group queue is drained only while it can actually be delivered. +let dynamicRenderFailureListenerCount = 0 + export type VoltraEventMap = { activityTokenReceived: VoltraActivityTokenReceivedEvent activityPushToStartTokenReceived: VoltraActivityPushToStartTokenReceivedEvent @@ -75,8 +80,23 @@ export function addVoltraListener( const subscription = voltraModule.onDynamicLiveActivityRenderFailed( listener as (arg: VoltraDynamicLiveActivityRenderFailedEvent) => void ) - voltraModule.drainDynamicLiveActivityRenderFailures() - return subscription + dynamicRenderFailureListenerCount += 1 + if (dynamicRenderFailureListenerCount === 1) { + voltraModule.setDynamicLiveActivityRenderFailureListenerActive(true) + } + + let removed = false + return { + remove: () => { + if (removed) return + removed = true + subscription.remove() + dynamicRenderFailureListenerCount -= 1 + if (dynamicRenderFailureListenerCount === 0) { + voltraModule.setDynamicLiveActivityRenderFailureListenerActive(false) + } + }, + } default: console.warn(`[Voltra] Event '${event}' is not supported. Returning no-op subscription.`) return noopSubscription diff --git a/packages/ios-client/src/native/NativeVoltra.ts b/packages/ios-client/src/native/NativeVoltra.ts index 9f8aaaf8..ea692a42 100644 --- a/packages/ios-client/src/native/NativeVoltra.ts +++ b/packages/ios-client/src/native/NativeVoltra.ts @@ -100,6 +100,7 @@ export interface Spec extends TurboModule { readonly onActivityTokenReceived: CodegenTypes.EventEmitter readonly onActivityPushToStartTokenReceived: CodegenTypes.EventEmitter drainDynamicLiveActivityRenderFailures(): void + setDynamicLiveActivityRenderFailureListenerActive(active: boolean): void startLiveActivity(jsonString: string, options: StartVoltraOptions): Promise updateLiveActivity(activityId: string, jsonString: string, options: UpdateVoltraOptions): Promise startDynamicLiveActivity(definitionId: string, propsJson: string, options: StartVoltraOptions): Promise diff --git a/packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts b/packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts index 6a11ef9a..01206242 100644 --- a/packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts +++ b/packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts @@ -7,25 +7,50 @@ jest.mock('../../src/VoltraModule.js', () => ({ getNativeVoltra: jest.fn() })) const mockedGetNativeVoltra = jest.mocked(getNativeVoltra) describe('Dynamic Live Activity render failure events', () => { - afterEach(() => jest.clearAllMocks()) + afterEach(() => { + jest.clearAllMocks() + }) it('subscribes through the dedicated native emitter with the exported event shape', () => { const subscription = { remove: jest.fn() } const onDynamicLiveActivityRenderFailed = jest.fn(() => subscription) - const drainDynamicLiveActivityRenderFailures = jest.fn() + const setDynamicLiveActivityRenderFailureListenerActive = jest.fn() mockedGetNativeVoltra.mockReturnValue({ onDynamicLiveActivityRenderFailed, - drainDynamicLiveActivityRenderFailures, + setDynamicLiveActivityRenderFailureListenerActive, } as unknown as Spec) const listener = jest.fn<(event: VoltraDynamicLiveActivityRenderFailedEvent) => void>() const returned = addVoltraListener('dynamicLiveActivityRenderFailed', listener) expect(onDynamicLiveActivityRenderFailed).toHaveBeenCalledWith(listener) - expect(drainDynamicLiveActivityRenderFailures).toHaveBeenCalledTimes(1) + expect(setDynamicLiveActivityRenderFailureListenerActive).toHaveBeenCalledWith(true) expect(onDynamicLiveActivityRenderFailed.mock.invocationCallOrder[0]).toBeLessThan( - drainDynamicLiveActivityRenderFailures.mock.invocationCallOrder[0]! + setDynamicLiveActivityRenderFailureListenerActive.mock.invocationCallOrder[0]! ) - expect(returned).toBe(subscription) + returned.remove() + expect(subscription.remove).toHaveBeenCalledTimes(1) + expect(setDynamicLiveActivityRenderFailureListenerActive).toHaveBeenLastCalledWith(false) + }) + + it('keeps native delivery ready until the final listener is removed, then re-enables it on resubscribe', () => { + const first = { remove: jest.fn() } + const second = { remove: jest.fn() } + const onDynamicLiveActivityRenderFailed = jest.fn().mockReturnValueOnce(first).mockReturnValueOnce(second) + const setDynamicLiveActivityRenderFailureListenerActive = jest.fn() + mockedGetNativeVoltra.mockReturnValue({ + onDynamicLiveActivityRenderFailed, + setDynamicLiveActivityRenderFailureListenerActive, + } as unknown as Spec) + + const firstSubscription = addVoltraListener('dynamicLiveActivityRenderFailed', jest.fn()) + const secondSubscription = addVoltraListener('dynamicLiveActivityRenderFailed', jest.fn()) + firstSubscription.remove() + expect(setDynamicLiveActivityRenderFailureListenerActive).toHaveBeenCalledTimes(1) + secondSubscription.remove() + expect(setDynamicLiveActivityRenderFailureListenerActive).toHaveBeenLastCalledWith(false) + + addVoltraListener('dynamicLiveActivityRenderFailed', jest.fn()) + expect(setDynamicLiveActivityRenderFailureListenerActive).toHaveBeenLastCalledWith(true) }) }) diff --git a/packages/metro/src/bundleWidgets.ts b/packages/metro/src/bundleWidgets.ts index bfa7940d..30cdd8e1 100644 --- a/packages/metro/src/bundleWidgets.ts +++ b/packages/metro/src/bundleWidgets.ts @@ -16,9 +16,12 @@ export type BundleWidgetsOptions = { projectRoot: string outDir: string platform: DynamicWidgetPlatform + /** Select bundles for a product target. The extension needs both; the app only needs activities. */ + content?: 'widgets' | 'live-activities' | 'all' } type ParsedArgs = { + content: 'widgets' | 'live-activities' | 'all' outDir: string | null platform: DynamicWidgetPlatform projectRoot: string @@ -33,7 +36,7 @@ function parsePlatform(value: string | undefined): DynamicWidgetPlatform { } export function parseBundleWidgetsArgs(argv: string[]): ParsedArgs { - const args: ParsedArgs = { outDir: null, platform: 'ios', projectRoot: process.cwd() } + const args: ParsedArgs = { content: 'all', outDir: null, platform: 'ios', projectRoot: process.cwd() } for (let i = 2; i < argv.length; i += 1) { const value = argv[i + 1] switch (argv[i]) { @@ -49,6 +52,13 @@ export function parseBundleWidgetsArgs(argv: string[]): ParsedArgs { args.projectRoot = path.resolve(value) i += 1 break + case '--content': + if (value !== 'widgets' && value !== 'live-activities' && value !== 'all') { + throw new Error(`Invalid content '${value}'. Expected widgets, live-activities, or all.`) + } + args.content = value + i += 1 + break default: break } @@ -61,7 +71,12 @@ async function loadAppMetroConfig(projectRoot: string): Promise { return loadConfig({ cwd: projectRoot }) } -export async function bundleWidgets({ projectRoot, outDir, platform }: BundleWidgetsOptions): Promise { +export async function bundleWidgets({ + projectRoot, + outDir, + platform, + content = 'all', +}: BundleWidgetsOptions): Promise { if (!outDir) { throw new Error('bundleWidgets: --out-dir is required') } @@ -70,9 +85,9 @@ export async function bundleWidgets({ projectRoot, outDir, platform }: BundleWid const liveActivityRegistry = platform === 'ios' ? createLiveActivityRegistry({ projectRoot }) : null try { - const widgets = registry.listWidgets(platform) + const widgets = content === 'live-activities' ? [] : registry.listWidgets(platform) let liveActivities: RegisteredVoltraLiveActivity[] = [] - if (liveActivityRegistry) { + if (liveActivityRegistry && content !== 'widgets') { try { liveActivities = liveActivityRegistry.listLiveActivities() } catch (error) { @@ -126,5 +141,6 @@ export async function runBundleWidgetsCli(argv = process.argv): Promise { projectRoot: args.projectRoot, outDir: args.outDir ?? '', platform: args.platform, + content: args.content, }) }