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/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. 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/app/_layout.tsx b/example/app/_layout.tsx index 20b3dee1..eed912d3 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -1,9 +1,13 @@ 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' +import '@use-voltra/live-activity-hot-reload' import { useVoltraEvents } from '~/hooks/useVoltraEvents' import { useServerDrivenWidgetToken } from '~/hooks/useServerDrivenWidgetToken' @@ -13,6 +17,7 @@ if (Platform.OS === 'android') { enableAndroidWidgetHotReload() } else { enableIosWidgetHotReload() + enableDynamicLiveActivityHotReload() } updateAndroidVoltraWidget({ width: 300, height: 200 }) 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/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/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/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/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/expo-plugin/jest.config.js b/packages/expo-plugin/jest.config.js index dcdfc6cb..5192c1f5 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/dynamic-live-activity$': '/../core/src/dynamic-live-activity.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..f2fca47b --- /dev/null +++ b/packages/expo-plugin/src/dynamic-live-activity.node.test.ts @@ -0,0 +1,18 @@ +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' + +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(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 new file mode 100644 index 00000000..92c2436b --- /dev/null +++ b/packages/expo-plugin/src/dynamic-live-activity.ts @@ -0,0 +1 @@ +export { getDynamicLiveActivityAttributesType } from '@use-voltra/core/dynamic-live-activity' diff --git a/packages/expo-plugin/src/index.ts b/packages/expo-plugin/src/index.ts index 8968f78f..bbfb49d2 100644 --- a/packages/expo-plugin/src/index.ts +++ b/packages/expo-plugin/src/index.ts @@ -1,6 +1,10 @@ export { MAX_IMAGE_SIZE_BYTES, MODULE_EXTENSIONS } from './constants' +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 641ace16..950e72f1 100644 --- a/packages/expo-plugin/src/types.ts +++ b/packages/expo-plugin/src/types.ts @@ -26,6 +26,31 @@ 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 + */ +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/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, 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/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/jest.config.js b/packages/ios-client/expo-plugin/jest.config.js index 4e24b4de..f5a51828 100644 --- a/packages/ios-client/expo-plugin/jest.config.js +++ b/packages/ios-client/expo-plugin/jest.config.js @@ -4,7 +4,9 @@ module.exports = { testMatch: ['/src/**/*.node.test.ts'], modulePathIgnorePatterns: ['/build'], moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', '^@use-voltra/compiler$': '/../../compiler/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/src/index.ts b/packages/ios-client/expo-plugin/src/index.ts index 1f9b7301..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( @@ -41,7 +50,9 @@ 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, + voltraVersion, }) config = withIOSWidget(config, { @@ -49,8 +60,10 @@ const withVoltraIos: VoltraIosConfigPlugin = (config, props = {}) => { bundleIdentifier, deploymentTarget, widgets: props.widgets, + liveActivities: props.liveActivities, version, buildNumber, + voltraVersion, ...(props.groupIdentifier ? { groupIdentifier: props.groupIdentifier } : {}), ...(keychainGroup ? { keychainGroup } : {}), ...(props.fonts ? { fonts: props.fonts } : {}), @@ -67,6 +80,7 @@ export default withVoltraIos export type { IOSConfigPluginProps, + IOSDynamicLiveActivityConfig, IOSMainAppPluginProps, IOSWidgetConfig, IOSWidgetExtensionFiles, 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..3beeffea --- /dev/null +++ b/packages/ios-client/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts @@ -0,0 +1,138 @@ +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}" + } + + ` +} + +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/index.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/index.ts index f4d9f734..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 @@ -2,20 +2,22 @@ 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 buildNumber: string + voltraVersion: string } /** @@ -31,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, groupIdentifier, keychainGroup, version, buildNumber } = props + const { targetName, widgets, liveActivities, groupIdentifier, keychainGroup, version, buildNumber, voltraVersion } = + props return withDangerousMod(config, [ 'ios', @@ -49,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/files/manifest.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/manifest.node.test.ts index a4308892..9bb7f6c9 100644 --- a/packages/ios-client/expo-plugin/src/ios-widget/files/manifest.node.test.ts +++ b/packages/ios-client/expo-plugin/src/ios-widget/files/manifest.node.test.ts @@ -2,7 +2,12 @@ import * as fs from 'fs' import * as os from 'os' import * as path from 'path' -import { createIOSDynamicWidgetsManifest, generateIOSDynamicWidgetsManifest } from './manifest' +import { + createIOSDynamicLiveActivitiesManifest, + createIOSDynamicWidgetsManifest, + generateIOSDynamicLiveActivitiesManifest, + generateIOSDynamicWidgetsManifest, +} from './manifest' function makeTempProject(files: Record): { 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-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.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/files/swift.node.test.ts index d82bbf61..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 @@ -1,3 +1,5 @@ +import { getDynamicLiveActivityAttributesType } from '../../../../../ios/src/live-activity/dynamic' + import type { DetectedIOSWidget } from '../clientRendered' import { __test__ } from './swift' @@ -74,6 +76,49 @@ 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( + `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('import VoltraRuntime') + expect(types).not.toContain('import VoltraWidget') + expect(types).toContain('@objc(VoltraGeneratedDynamicLiveActivityRegistration)') + expect(types).toContain('public final class VoltraGeneratedDynamicLiveActivityRegistration: NSObject') + expect(types).toContain( + 'VoltraDynamicLiveActivityRegistry.shared.register(VoltraDriverArrivedLiveActivityAttributes.self)' + ) + expect(types).toContain( + '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])') + 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 VoltraRuntime') + 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..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 @@ -12,15 +12,23 @@ 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' +import { + generateDynamicLiveActivitiesSwift, + generateDynamicLiveActivityTypesSwift, + generateDynamicLiveActivityWidgetInstances, +} from '../dynamic-live-activity/swift' + +import { escapeForSwiftStringLiteral } from './swift-utils' export interface GenerateSwiftFilesOptions { targetPath: string projectRoot: string widgets?: IOSWidgetConfig[] + liveActivities?: IOSDynamicLiveActivityConfig[] } type RenderWidgetToString = (variants: unknown) => string @@ -37,7 +45,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 +84,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` @@ -155,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) } @@ -443,7 +459,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 +476,12 @@ function generateWidgetBundleSwift(widgets: DetectedIOSWidget[]): string { .map((w) => `VoltraWidget_${w.id}()`) .join('\n ')}\n }` : '' - const widgetInstances = [plainInstances, appIntentInstances].filter(Boolean).join('\n ') + const dynamicLiveActivityInstances = generateDynamicLiveActivityWidgetInstances(liveActivities) + 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' : '' @@ -473,7 +497,7 @@ function generateWidgetBundleSwift(widgets: DetectedIOSWidget[]): string { ${foundationImport}${appIntentsImport}import SwiftUI import WidgetKit - import VoltraWidget + import VoltraRuntime @main struct VoltraWidgetBundle: WidgetBundle { @@ -481,7 +505,7 @@ function generateWidgetBundleSwift(widgets: DetectedIOSWidget[]): string { // Live Activity (with Watch/CarPlay support) VoltraWidget() - // Home Screen Widgets + // ${widgetSectionTitle} ${widgetInstances} } } @@ -493,8 +517,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` @@ -507,7 +531,7 @@ function generateDefaultWidgetBundleSwift(): string { import SwiftUI import WidgetKit - import VoltraWidget // Import Voltra widgets + import VoltraRuntime // Import Voltra widgets @main struct VoltraWidgetBundle: WidgetBundle { @@ -624,4 +648,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..db996b69 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,11 +13,13 @@ export interface WithIOSProps { bundleIdentifier: string deploymentTarget: string widgets?: IOSWidgetConfig[] + liveActivities?: IOSDynamicLiveActivityConfig[] groupIdentifier?: string keychainGroup?: string fonts?: string[] version: string buildNumber: string + voltraVersion: string } /** @@ -42,11 +44,13 @@ export const withIOS: ConfigPlugin = (config, props) => { bundleIdentifier, deploymentTarget, widgets, + liveActivities, groupIdentifier, keychainGroup, fonts, version, buildNumber, + voltraVersion, } = props const plugins: [ConfigPlugin, any][] = [ @@ -54,7 +58,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 +73,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, voltraVersion }, + ], ] return withPlugins(config, plugins) 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/applyXcodeChanges.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/applyXcodeChanges.node.test.ts index 27edf1b9..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 @@ -34,6 +34,40 @@ 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 appTarget = project.getFirstTarget().uuid + const shellPhases = objects.PBXShellScriptBuildPhase || {} + 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]) => + !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.node.test.ts b/packages/ios-client/expo-plugin/src/ios-widget/xcode/buildPhases.node.test.ts index 6372b1a8..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 @@ -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') @@ -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 a5fd6e65..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 @@ -6,7 +6,8 @@ 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' +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 Widgets' // 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 @@ -112,6 +146,8 @@ export interface EnsureBuildPhasesOptions { } widgetFiles: IOSWidgetExtensionFiles mainTargetUuid?: string + /** Generated ActivityKit types compiled into both the extension and the app. */ + mainSwiftFiles?: string[] } /** @@ -191,6 +227,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..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 @@ -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[] } /** @@ -42,6 +43,13 @@ 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 = { + ...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) @@ -92,7 +100,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. @@ -101,13 +109,22 @@ export function applyXcodeChanges( targetName, groupName, productFile, - widgetFiles, + widgetFiles: effectiveWidgetFiles, mainTargetUuid: xcodeProject.getFirstTarget().uuid, + // 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) { + 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, 'live-activities') + } ensureTargetAttributes(xcodeProject, targetUuid) ensureTargetDependency(xcodeProject, targetUuid) @@ -128,7 +145,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 +164,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..33547644 100644 --- a/packages/ios-client/expo-plugin/src/ios/index.ts +++ b/packages/ios-client/expo-plugin/src/ios/index.ts @@ -8,7 +8,9 @@ export interface IOSConfigProps { groupIdentifier?: string widgetIds?: string[] widgets?: import('../types').IOSWidgetConfig[] + liveActivities?: import('../types').IOSDynamicLiveActivityConfig[] keychainGroup?: string + voltraVersion: string } /** @@ -27,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/expo-plugin/src/types.ts b/packages/ios-client/expo-plugin/src/types.ts index e52ad60a..e72a70c1 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[] @@ -111,6 +124,7 @@ export interface IOSWidgetExtensionPluginProps { bundleIdentifier: string deploymentTarget: string widgets?: IOSWidgetConfig[] + liveActivities?: IOSDynamicLiveActivityConfig[] groupIdentifier?: string keychainGroup?: 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-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/ios/Package.swift b/packages/ios-client/ios/Package.swift index 451f7711..5e779927 100644 --- a/packages/ios-client/ios/Package.swift +++ b/packages/ios-client/ios/Package.swift @@ -42,6 +42,10 @@ let package = Package( "DynamicWidgetPropsStore.swift", "DynamicWidgetRenderCoordinator.swift", "DynamicWidgetUpdater.swift", + "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/DynamicLiveActivityPayloadValidatorTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityPayloadValidatorTests.swift new file mode 100644 index 00000000..ec03e443 --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityPayloadValidatorTests.swift @@ -0,0 +1,85 @@ +@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)") + } + } + } + + 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/Tests/VoltraSharedTests/DynamicLiveActivityRenderFailureQueueTests.swift b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityRenderFailureQueueTests.swift new file mode 100644 index 00000000..13b96396 --- /dev/null +++ b/packages/ios-client/ios/Tests/VoltraSharedTests/DynamicLiveActivityRenderFailureQueueTests.swift @@ -0,0 +1,127 @@ +@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"]) + } + + 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)", + definitionId: "definition", + message: "failure \(index)", + timestamp: Date(timeIntervalSince1970: Double(index)) + ) + } +} + +private final class InMemoryRenderFailureStorage: VoltraDynamicLiveActivityRenderFailureStorage { + var failures: [VoltraDynamicLiveActivityRenderFailure] = [] + var interactionEvents: [String] = [] + + 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 drain() throws -> [VoltraDynamicLiveActivityRenderFailure] { + lock.lock() + defer { lock.unlock() } + defer { failures.removeAll() } + return failures + } +} 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/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 30fb94c0..42c7864c 100644 --- a/packages/ios-client/ios/app/NativeVoltra.mm +++ b/packages/ios-client/ios/app/NativeVoltra.mm @@ -1,12 +1,25 @@ #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 +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 @@ -65,6 +78,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,9 +101,20 @@ - (VoltraModule *)module - (void)applicationWillEnterForeground { [self.module clearHeadless]; + [self.module drainDynamicLiveActivityRenderFailures]; [self updateRootAppPropertiesHeadless:NO]; } +- (void)drainDynamicLiveActivityRenderFailures +{ + [self.module drainDynamicLiveActivityRenderFailures]; +} + +- (void)setDynamicLiveActivityRenderFailureListenerActive:(BOOL)active +{ + [self.module setDynamicLiveActivityRenderFailureListenerActive:active]; +} + - (UIView *)reactRootViewInView:(UIView *)view { if ([view respondsToSelector:NSSelectorFromString(@"appProperties")] && @@ -193,7 +219,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); } }]; } @@ -207,7 +233,38 @@ - (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); } + }]; +} + +- (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) { VoltraRejectPromise(reject, @"startDynamicLiveActivity", 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) { VoltraRejectPromise(reject, @"updateDynamicLiveActivity", error); } else { resolve(nil); } }]; } @@ -224,14 +281,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); } }]; } @@ -245,6 +302,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]); @@ -271,6 +333,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..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)? @@ -30,6 +33,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 +46,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 @@ -52,13 +61,20 @@ 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 + ) } // MARK: - Public API @@ -69,9 +85,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 VoltraDynamicLiveActivityRegistry.shared.startObserving(with: dynamicObserver) + } } /// Stop all observation and cancel every outstanding task. @@ -79,6 +100,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 +110,13 @@ public actor VoltraLiveActivityManager { lastPushToStartToken = nil cancelAllPerActivityTasks() + Task { [dynamicObserver] in + await dynamicObserver.stopObserving() + } + } + + private func currentlyObserving() -> Bool { + isObserving } // MARK: - deinit @@ -169,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 dc347029..79afcff3 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,30 @@ 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 || dynamicService.isActive(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 chronology.order(legacy + dynamicService.activityReferences()) + } + + public func latestActivityId() -> String? { + 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 + /// depend on Metro, the app group, or a server connection. + public func dynamicLiveActivityDefinitionIds() -> [String] { + dynamicService.definitionIds() } // MARK: - Create Operations @@ -154,7 +180,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, @@ -163,6 +189,7 @@ public class VoltraLiveActivityService { ), pushType: request.pushType ) + chronology.record(activity.id) return finalActivityId } @@ -202,6 +229,9 @@ public class VoltraLiveActivityService { request: UpdateActivityRequest ) async throws { guard let activity = findActivity(byName: name) else { + if dynamicService.isActive(name: name) { + throw VoltraLiveActivityError.rendererMismatch + } throw VoltraLiveActivityError.notFound } try await updateActivity(activity, request: request) @@ -231,10 +261,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 dynamicService.end(byName: name, dismissalPolicy: dismissalPolicy) + return + } + guard await dynamicService.end(byName: name, dismissalPolicy: dismissalPolicy) else { throw VoltraLiveActivityError.notFound } - await endActivity(activity, dismissalPolicy: dismissalPolicy) } /// End all activities with the same name @@ -245,6 +280,7 @@ public class VoltraLiveActivityService { for activity in activities { await endActivity(activity) } + _ = await dynamicService.end(byName: name, dismissalPolicy: .immediate) } /// End all Voltra Live Activities @@ -254,6 +290,34 @@ public class VoltraLiveActivityService { for activity in activities { await endActivity(activity) } + await dynamicService.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 } + if request.name.isEmpty == false { + try await endActivities(byName: 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 } + if try await dynamicService.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, + /// 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 + await dynamicService.reload(definitionIds: definitionIds) + #endif } // MARK: - Monitoring @@ -280,6 +344,9 @@ public class VoltraLiveActivityService { } let manager = VoltraLiveActivityManager( + onActivityDiscovered: { [chronology] activityId in + chronology.record(activityId) + }, onTokenUpdated: onTokenUpdated, onPushToStartUpdated: onPushToStartUpdated, onStateChanged: { activityName, state in @@ -305,4 +372,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..c758f95f 100644 --- a/packages/ios-client/ios/app/VoltraModule.swift +++ b/packages/ios-client/ios/app/VoltraModule.swift @@ -1,10 +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 { @@ -59,6 +91,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?, @@ -93,6 +156,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) } @@ -105,6 +172,14 @@ public enum VoltraErrors: Error { impl.clearHeadless() } + @objc public func drainDynamicLiveActivityRenderFailures() { + impl.drainDynamicLiveActivityRenderFailures() + } + + @objc public func setDynamicLiveActivityRenderFailureListenerActive(_ active: Bool) { + impl.setDynamicLiveActivityRenderFailureListenerActive(active) + } + // MARK: - Images @objc public func preloadImages( @@ -136,6 +211,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 2aab439f..eb3b512d 100644 --- a/packages/ios-client/ios/app/VoltraModuleImpl.swift +++ b/packages/ios-client/ios/app/VoltraModuleImpl.swift @@ -89,6 +89,14 @@ public class VoltraModuleImpl { VoltraHeadlessState.shared.clear() } + func drainDynamicLiveActivityRenderFailures() { + VoltraEventBus.shared.drainDynamicLiveActivityRenderFailures() + } + + func setDynamicLiveActivityRenderFailureListenerActive(_ active: Bool) { + VoltraEventBus.shared.setDynamicLiveActivityRenderFailureListenerActive(active) + } + var pushNotificationsEnabled: Bool { // Support both keys for compatibility with older plugin and new Voltra naming let main = Bundle.main @@ -182,6 +190,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 +252,17 @@ 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 getDynamicLiveActivityDefinitionIds() -> [String] { + guard #available(iOS 16.4, *) else { return [] } + return liveActivityService.dynamicLiveActivityDefinitionIds() } func isLiveActivityActive(name: String) -> Bool { @@ -245,6 +300,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 { @@ -342,6 +404,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/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/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..1ce70062 --- /dev/null +++ b/packages/ios-client/ios/app/dynamic-live-activity/VoltraLiveActivityChronology.swift @@ -0,0 +1,68 @@ +import Foundation + +/// 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() + + private static let storageKey = "Voltra_LiveActivityChronology" + + private let lock = NSLock() + private let defaults: UserDefaults + private var timestamps: [String: TimeInterval] + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + 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 timestamps[activityId] == nil else { return } + timestamps[activityId] = Date().timeIntervalSince1970 + persist() + } + + func order( + _ references: [VoltraDynamicLiveActivityReference] + ) -> [VoltraDynamicLiveActivityReference] { + lock.lock() + defer { lock.unlock() } + + let activeIds = Set(references.map(\.id)) + let nextTimestamps = timestamps.filter { activeIds.contains($0.key) } + if nextTimestamps != timestamps { + timestamps = nextTimestamps + persist() + } + 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(timestamps, forKey: Self.storageKey) + } +} 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/VoltraEventBus.swift b/packages/ios-client/ios/shared/VoltraEventBus.swift index c1934e81..6041a3dd 100644 --- a/packages/ios-client/ios/shared/VoltraEventBus.swift +++ b/packages/ios-client/ios/shared/VoltraEventBus.swift @@ -8,6 +8,9 @@ public class VoltraEventBus { public static let shared = VoltraEventBus() private var observer: NSObjectProtocol? + private var renderFailureObserver: UUID? + private var handler: ((String, [String: Any]) -> Void)? + private var renderFailureListenerCount = 0 private let lock = NSLock() private init() {} @@ -39,20 +42,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 +67,42 @@ 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") + } + + /// 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() + renderFailureListenerCount = active ? 1 : 0 + lock.unlock() + if active { 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 + let isReady = renderFailureListenerCount > 0 + lock.unlock() + guard isReady, 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 +114,12 @@ public class VoltraEventBus { NotificationCenter.default.removeObserver(observer) self.observer = nil } + if let renderFailureObserver { + VoltraDynamicLiveActivityRenderFailureReporter.removeChangeObserver(renderFailureObserver) + self.renderFailureObserver = nil + } + handler = nil + renderFailureListenerCount = 0 } deinit { diff --git a/packages/ios-client/ios/shared/VoltraJSRenderer.swift b/packages/ios-client/ios/shared/VoltraJSRenderer.swift index 1a1a2212..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" @@ -30,6 +31,28 @@ 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 { + 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 { lock.lock() defer { lock.unlock() } @@ -38,7 +61,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 +73,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 +113,29 @@ 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 { + 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 { lock.lock() let alreadyEvaluated = _context? - .objectForKeyedSubscript("__voltraWidgets")? - .objectForKeyedSubscript(widgetId)? + .objectForKeyedSubscript(registryName)? + .objectForKeyedSubscript(id)? .objectForKeyedSubscript("render")? .isObject ?? false lock.unlock() @@ -102,7 +143,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 +155,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..6f525763 --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityBundleSource.swift @@ -0,0 +1,136 @@ +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 + 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 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>? + 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 { + result = .success((data, response)) + } else { + result = .failure(LoadError.metroHTTP(-1)) + } + semaphore.signal() + } + 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) + } + guard let source = String(data: data, encoding: .utf8) else { + throw LoadError.nonUTF8 + } + 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"), + 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/VoltraDynamicLiveActivityObserver.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityObserver.swift new file mode 100644 index 00000000..03ea8e58 --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityObserver.swift @@ -0,0 +1,97 @@ +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 onActivityDiscovered: (@Sendable (String) -> Void)? + 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( + 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 + } + + 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 + onActivityDiscovered?(activity.id) + + 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 new file mode 100644 index 00000000..37b1f95e --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift @@ -0,0 +1,105 @@ +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 -> VoltraDynamicLiveActivityReference { + let attributes = Attributes(name: request.name, deepLinkUrl: request.deepLinkUrl) + let state = VoltraDynamicLiveActivityContentState(props: request.props) + 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( + _: 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) + } + } + + public static func reload( + _: Attributes.Type + ) async { + for activity in Activity.activities { + await activity.update(ActivityContent( + state: activity.content.state, + staleDate: activity.content.staleDate, + relevanceScore: activity.content.relevanceScore + )) + } + } +} 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..77f870b2 --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift @@ -0,0 +1,55 @@ +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 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 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 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 validateSize(_ size: Int) throws { + guard size <= VoltraConstants.maxPayloadSizeBytes else { + throw VoltraDynamicLiveActivityError.payloadTooLarge(size: size) + } + } +} 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/VoltraDynamicLiveActivityRenderFailureQueue.swift b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift new file mode 100644 index 00000000..9fee8509 --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift @@ -0,0 +1,158 @@ +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. +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 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 whose storage owns the append/drain transaction. +public final class VoltraDynamicLiveActivityRenderFailureQueue { + public static let capacity = 100 + + private let storage: VoltraDynamicLiveActivityRenderFailureStorage + public init(storage: VoltraDynamicLiveActivityRenderFailureStorage) { + self.storage = storage + } + + @discardableResult + public func record(_ failure: VoltraDynamicLiveActivityRenderFailure) -> Bool { + do { + try storage.append(failure, capacity: Self.capacity) + return true + } catch { + return false + } + } + + public func drain() -> [VoltraDynamicLiveActivityRenderFailure] { + do { + 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 new file mode 100644 index 00000000..f0cbb299 --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift @@ -0,0 +1,120 @@ +import CoreFoundation +import Foundation + +/// 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 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 { + let failure = VoltraDynamicLiveActivityRenderFailure( + activityName: activityName, + definitionId: definitionId, + message: message + ) + let persisted = queue?.record(failure) ?? false + 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 new file mode 100644 index 00000000..4e12f2e3 --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift @@ -0,0 +1,359 @@ +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 { + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandExpandedLeading + ) + } + DynamicIslandExpandedRegion(.trailing) { + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandExpandedTrailing + ) + } + DynamicIslandExpandedRegion(.center) { + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandExpandedCenter + ) + } + DynamicIslandExpandedRegion(.bottom) { + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandExpandedBottom + ) + } + } compactLeading: { + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandCompactLeading + ) + } compactTrailing: { + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandCompactTrailing + ) + } minimal: { + VoltraDynamicLiveActivityDynamicIslandRegionView( + definitionId: definitionId, + context: context, + region: .islandMinimal + ) + } + + // 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( + definitionId: String, + context: ActivityViewContext, + activityFamily: String?, + colorScheme: ColorScheme? = nil, + locale: Locale = .current, + widgetRenderingMode: WidgetRenderingMode = .fullColor + ) -> VoltraDynamicLiveActivityResolvedContent { + guard VoltraDynamicLiveActivityRegistry.shared.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, 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, + 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, activityName: context.attributes.name, message: "Could not evaluate definition bundle") + return .empty + } + guard let renderedJSON = VoltraJSRenderer.renderLiveActivity( + definitionId: definitionId, + propsJSON: propsJSON, + envJSON: environmentJSON + ) else { + 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, activityName: context.attributes.name, message: error.localizedDescription) + return .empty + } + } + + fileprivate static func logFailure(definitionId: String, activityName: String, message: String) { + VoltraDynamicLiveActivityRenderFailureReporter.record( + activityName: activityName, + definitionId: definitionId, + message: message + ) + } +} + +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 + + @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.flatMap(VoltraDeepLinkResolver.resolveUrl)) { view, url in view.widgetURL(url) } + } + } +} + +/// 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, + 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": Bundle.main.object(forInfoDictionaryKey: VoltraStorageKeys.voltraVersion) as? String ?? "unknown", + ] + 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..d6590e8d --- /dev/null +++ b/packages/ios-client/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift @@ -0,0 +1,87 @@ +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 +/// 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 + } +} + +public enum VoltraDynamicLiveActivityError: Error { + case unknownDefinition(String) + case payloadTooLarge(size: Int) + case rendererMismatch + case resourceUnavailable(Error) +} + +// 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 } + var name: String { get } + var deepLinkUrl: String? { get } + init(name: String, deepLinkUrl: String?) + } +#endif 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 + } +} 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 diff --git a/packages/ios-client/jest.dynamic-widget.config.js b/packages/ios-client/jest.dynamic-widget.config.js index ba745b74..7dd2f0e0 100644 --- a/packages/ios-client/jest.dynamic-widget.config.js +++ b/packages/ios-client/jest.dynamic-widget.config.js @@ -1,7 +1,11 @@ /** @type {import('jest').Config} */ module.exports = { testEnvironment: 'node', - testMatch: ['/tests/dynamic-widget/**/*.node.test.ts'], + setupFiles: ['/tests/setup.node.js'], + 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/events.ts b/packages/ios-client/src/events.ts index 171251dc..e33ef0d6 100644 --- a/packages/ios-client/src/events.ts +++ b/packages/ios-client/src/events.ts @@ -30,15 +30,28 @@ export type VoltraInteractionEvent = BasicVoltraEvent & { payload: string } +export type VoltraDynamicLiveActivityRenderFailedEvent = BasicVoltraEvent & { + type: 'dynamicLiveActivityRenderFailed' + activityName: string + definitionId: string + message: string +} + 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 stateChange: VoltraActivityUpdateEvent interaction: VoltraInteractionEvent + dynamicLiveActivityRenderFailed: VoltraDynamicLiveActivityRenderFailedEvent } export function addVoltraListener( @@ -63,6 +76,27 @@ export function addVoltraListener( return voltraModule.onStateChanged(listener as (arg: VoltraActivityUpdateEvent) => void) case 'interaction': return voltraModule.onInteraction(listener as (arg: VoltraInteractionEvent) => void) + case 'dynamicLiveActivityRenderFailed': + const subscription = voltraModule.onDynamicLiveActivityRenderFailed( + listener as (arg: VoltraDynamicLiveActivityRenderFailedEvent) => void + ) + 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/index.ts b/packages/ios-client/src/index.ts index 3592fd59..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, @@ -33,6 +45,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/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/src/native/NativeVoltra.ts b/packages/ios-client/src/native/NativeVoltra.ts index 2796ac92..ea692a42 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,19 +95,26 @@ 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 + 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 + updateDynamicLiveActivity(activityId: string, propsJson: string, options: UpdateVoltraOptions): Promise endLiveActivity(activityId: string, options: EndVoltraOptions): Promise 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/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/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/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..01206242 --- /dev/null +++ b/packages/ios-client/tests/dynamic-live-activity/renderFailureEvents.node.test.ts @@ -0,0 +1,56 @@ +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) + const setDynamicLiveActivityRenderFailureListenerActive = jest.fn() + mockedGetNativeVoltra.mockReturnValue({ + onDynamicLiveActivityRenderFailed, + setDynamicLiveActivityRenderFailureListenerActive, + } as unknown as Spec) + const listener = jest.fn<(event: VoltraDynamicLiveActivityRenderFailedEvent) => void>() + + const returned = addVoltraListener('dynamicLiveActivityRenderFailed', listener) + + expect(onDynamicLiveActivityRenderFailed).toHaveBeenCalledWith(listener) + expect(setDynamicLiveActivityRenderFailureListenerActive).toHaveBeenCalledWith(true) + expect(onDynamicLiveActivityRenderFailed.mock.invocationCallOrder[0]).toBeLessThan( + setDynamicLiveActivityRenderFailureListenerActive.mock.invocationCallOrder[0]! + ) + 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/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 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-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-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"], diff --git a/packages/ios/src/index.ts b/packages/ios/src/index.ts index f296104f..ecb574b2 100644 --- a/packages/ios/src/index.ts +++ b/packages/ios/src/index.ts @@ -8,12 +8,19 @@ 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, + LiveActivityEnvironment, 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..f68153c3 --- /dev/null +++ b/packages/ios/src/live-activity/dynamic.ts @@ -0,0 +1,24 @@ +/** + * 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 +} + +export { getDynamicLiveActivityAttributesType } from '@use-voltra/core/dynamic-live-activity' 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/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/src/bundleWidgets.ts b/packages/metro/src/bundleWidgets.ts index 6e208ac7..30cdd8e1 100644 --- a/packages/metro/src/bundleWidgets.ts +++ b/packages/metro/src/bundleWidgets.ts @@ -6,14 +6,22 @@ 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 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 @@ -28,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]) { @@ -44,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 } @@ -56,18 +71,32 @@ 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') } const registry = createWidgetRegistry({ projectRoot }) + 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 && content !== 'widgets') { + 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 +122,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() } } @@ -104,5 +141,6 @@ export async function runBundleWidgetsCli(argv = process.argv): Promise { projectRoot: args.projectRoot, outDir: args.outDir ?? '', platform: args.platform, + content: args.content, }) } 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..0ba9fd5e --- /dev/null +++ b/packages/metro/src/liveActivityRegistry.ts @@ -0,0 +1,239 @@ +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 }`, + 'if (typeof globalThis.__voltraDynamicLiveActivityDefinitionUpdated === "function") {', + ` globalThis.__voltraDynamicLiveActivityDefinitionUpdated(${JSON.stringify(definition.id)})`, + '}', + '', + '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..8998a8cc 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,59 @@ 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, /__voltraDynamicLiveActivityDefinitionUpdated/) + assert.match(generated, /\("order"\)/) + 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 +352,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 +479,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']) + }) }) 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"], 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) 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/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/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. 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.