@use-voltra/ios-client 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -8
- package/Voltra.podspec +1 -0
- package/build/commonjs/dynamic-widget/api.js +15 -0
- package/build/commonjs/dynamic-widget/api.js.map +1 -0
- package/build/commonjs/events.js +23 -0
- package/build/commonjs/events.js.map +1 -1
- package/build/commonjs/index.js +74 -7
- package/build/commonjs/index.js.map +1 -1
- package/build/commonjs/live-activity/api.js +3 -1
- package/build/commonjs/live-activity/api.js.map +1 -1
- package/build/commonjs/live-activity/dynamic-api.js +173 -0
- package/build/commonjs/live-activity/dynamic-api.js.map +1 -0
- package/build/commonjs/native/NativeVoltra.js.map +1 -1
- package/build/commonjs/utils/enableDynamicLiveActivityHotReload.js +47 -0
- package/build/commonjs/utils/enableDynamicLiveActivityHotReload.js.map +1 -0
- package/build/commonjs/utils/enableWidgetHotReload.js +8 -11
- package/build/commonjs/utils/enableWidgetHotReload.js.map +1 -1
- package/build/commonjs/utils/useUpdateOnHMR.js +3 -7
- package/build/commonjs/utils/useUpdateOnHMR.js.map +1 -1
- package/build/commonjs/widgets/server-credentials.js +9 -0
- package/build/commonjs/widgets/server-credentials.js.map +1 -1
- package/build/commonjs/widgets/server-update.js +78 -0
- package/build/commonjs/widgets/server-update.js.map +1 -0
- package/build/module/dynamic-widget/api.js +10 -0
- package/build/module/dynamic-widget/api.js.map +1 -0
- package/build/module/events.js +23 -0
- package/build/module/events.js.map +1 -1
- package/build/module/index.js +4 -0
- package/build/module/index.js.map +1 -1
- package/build/module/live-activity/api.js +2 -2
- package/build/module/live-activity/api.js.map +1 -1
- package/build/module/live-activity/dynamic-api.js +167 -0
- package/build/module/live-activity/dynamic-api.js.map +1 -0
- package/build/module/native/NativeVoltra.js.map +1 -1
- package/build/module/utils/enableDynamicLiveActivityHotReload.js +43 -0
- package/build/module/utils/enableDynamicLiveActivityHotReload.js.map +1 -0
- package/build/module/utils/enableWidgetHotReload.js +9 -11
- package/build/module/utils/enableWidgetHotReload.js.map +1 -1
- package/build/module/utils/useUpdateOnHMR.js +3 -7
- package/build/module/utils/useUpdateOnHMR.js.map +1 -1
- package/build/module/widgets/server-credentials.js +9 -0
- package/build/module/widgets/server-credentials.js.map +1 -1
- package/build/module/widgets/server-update.js +72 -0
- package/build/module/widgets/server-update.js.map +1 -0
- package/build/typescript/commonjs/dynamic-widget/api.d.ts +8 -0
- package/build/typescript/commonjs/dynamic-widget/api.d.ts.map +1 -0
- package/build/typescript/commonjs/events.d.ts +7 -0
- package/build/typescript/commonjs/events.d.ts.map +1 -1
- package/build/typescript/commonjs/index.d.ts +4 -0
- package/build/typescript/commonjs/index.d.ts.map +1 -1
- package/build/typescript/commonjs/live-activity/api.d.ts +7 -0
- package/build/typescript/commonjs/live-activity/api.d.ts.map +1 -1
- package/build/typescript/commonjs/live-activity/dynamic-api.d.ts +54 -0
- package/build/typescript/commonjs/live-activity/dynamic-api.d.ts.map +1 -0
- package/build/typescript/commonjs/native/NativeVoltra.d.ts +21 -0
- package/build/typescript/commonjs/native/NativeVoltra.d.ts.map +1 -1
- package/build/typescript/commonjs/types.d.ts +1 -1
- package/build/typescript/commonjs/types.d.ts.map +1 -1
- package/build/typescript/commonjs/utils/enableDynamicLiveActivityHotReload.d.ts +15 -0
- package/build/typescript/commonjs/utils/enableDynamicLiveActivityHotReload.d.ts.map +1 -0
- package/build/typescript/commonjs/utils/enableWidgetHotReload.d.ts +5 -7
- package/build/typescript/commonjs/utils/enableWidgetHotReload.d.ts.map +1 -1
- package/build/typescript/commonjs/utils/useUpdateOnHMR.d.ts +0 -3
- package/build/typescript/commonjs/utils/useUpdateOnHMR.d.ts.map +1 -1
- package/build/typescript/commonjs/widgets/server-credentials.d.ts +8 -0
- package/build/typescript/commonjs/widgets/server-credentials.d.ts.map +1 -1
- package/build/typescript/commonjs/widgets/server-update.d.ts +64 -0
- package/build/typescript/commonjs/widgets/server-update.d.ts.map +1 -0
- package/build/typescript/module/dynamic-widget/api.d.ts +8 -0
- package/build/typescript/module/dynamic-widget/api.d.ts.map +1 -0
- package/build/typescript/module/events.d.ts +7 -0
- package/build/typescript/module/events.d.ts.map +1 -1
- package/build/typescript/module/index.d.ts +4 -0
- package/build/typescript/module/index.d.ts.map +1 -1
- package/build/typescript/module/live-activity/api.d.ts +7 -0
- package/build/typescript/module/live-activity/api.d.ts.map +1 -1
- package/build/typescript/module/live-activity/dynamic-api.d.ts +54 -0
- package/build/typescript/module/live-activity/dynamic-api.d.ts.map +1 -0
- package/build/typescript/module/native/NativeVoltra.d.ts +21 -0
- package/build/typescript/module/native/NativeVoltra.d.ts.map +1 -1
- package/build/typescript/module/types.d.ts +1 -1
- package/build/typescript/module/types.d.ts.map +1 -1
- package/build/typescript/module/utils/enableDynamicLiveActivityHotReload.d.ts +15 -0
- package/build/typescript/module/utils/enableDynamicLiveActivityHotReload.d.ts.map +1 -0
- package/build/typescript/module/utils/enableWidgetHotReload.d.ts +5 -7
- package/build/typescript/module/utils/enableWidgetHotReload.d.ts.map +1 -1
- package/build/typescript/module/utils/useUpdateOnHMR.d.ts +0 -3
- package/build/typescript/module/utils/useUpdateOnHMR.d.ts.map +1 -1
- package/build/typescript/module/widgets/server-credentials.d.ts +8 -0
- package/build/typescript/module/widgets/server-credentials.d.ts.map +1 -1
- package/build/typescript/module/widgets/server-update.d.ts +64 -0
- package/build/typescript/module/widgets/server-update.d.ts.map +1 -0
- package/expo-plugin/build/cjs/constants.js +20 -1
- package/expo-plugin/build/cjs/constants.js.map +1 -1
- package/expo-plugin/build/cjs/index.js +9 -1
- package/expo-plugin/build/cjs/index.js.map +1 -1
- package/expo-plugin/build/cjs/ios/index.js +1 -0
- package/expo-plugin/build/cjs/ios/index.js.map +1 -1
- package/expo-plugin/build/cjs/ios/infoPlist.js +28 -5
- package/expo-plugin/build/cjs/ios/infoPlist.js.map +1 -1
- package/expo-plugin/build/cjs/ios/serverUpdate.js +25 -0
- package/expo-plugin/build/cjs/ios/serverUpdate.js.map +1 -0
- package/expo-plugin/build/cjs/ios-widget/clientRendered.js +4 -4
- package/expo-plugin/build/cjs/ios-widget/clientRendered.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js +5 -5
- package/expo-plugin/build/cjs/ios-widget/clientRenderedPrerender.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/dynamic-live-activity/swift.js +130 -0
- package/expo-plugin/build/cjs/ios-widget/dynamic-live-activity/swift.js.map +1 -0
- package/expo-plugin/build/cjs/ios-widget/files/index.js +9 -2
- package/expo-plugin/build/cjs/ios-widget/files/index.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/files/infoPlist.js +6 -3
- package/expo-plugin/build/cjs/ios-widget/files/infoPlist.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/files/manifest.js +24 -0
- package/expo-plugin/build/cjs/ios-widget/files/manifest.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/files/swift-utils.js +7 -0
- package/expo-plugin/build/cjs/ios-widget/files/swift-utils.js.map +1 -0
- package/expo-plugin/build/cjs/ios-widget/files/swift.js +56 -26
- package/expo-plugin/build/cjs/ios-widget/files/swift.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/index.js +9 -3
- package/expo-plugin/build/cjs/ios-widget/index.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/podfile.js +38 -12
- package/expo-plugin/build/cjs/ios-widget/podfile.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/widgetPlist.js +34 -22
- package/expo-plugin/build/cjs/ios-widget/widgetPlist.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js +194 -118
- package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/configurationList.js +30 -22
- package/expo-plugin/build/cjs/ios-widget/xcode/configurationList.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/fileReferences.js +50 -0
- package/expo-plugin/build/cjs/ios-widget/xcode/fileReferences.js.map +1 -0
- package/expo-plugin/build/cjs/ios-widget/xcode/groups.js +45 -43
- package/expo-plugin/build/cjs/ios-widget/xcode/groups.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/index.js +93 -91
- package/expo-plugin/build/cjs/ios-widget/xcode/index.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/mainAppSettings.js +41 -39
- package/expo-plugin/build/cjs/ios-widget/xcode/mainAppSettings.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/productFile.js +2 -2
- package/expo-plugin/build/cjs/ios-widget/xcode/productFile.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/target.js +29 -63
- package/expo-plugin/build/cjs/ios-widget/xcode/target.js.map +1 -1
- package/expo-plugin/build/cjs/utils/urlScheme.js +36 -14
- package/expo-plugin/build/cjs/utils/urlScheme.js.map +1 -1
- package/expo-plugin/build/cjs/validation.js +54 -0
- package/expo-plugin/build/cjs/validation.js.map +1 -1
- package/expo-plugin/build/esm/constants.js +17 -0
- package/expo-plugin/build/esm/constants.js.map +1 -1
- package/expo-plugin/build/esm/index.js +9 -1
- package/expo-plugin/build/esm/index.js.map +1 -1
- package/expo-plugin/build/esm/ios/index.js +1 -0
- package/expo-plugin/build/esm/ios/index.js.map +1 -1
- package/expo-plugin/build/esm/ios/infoPlist.js +28 -5
- package/expo-plugin/build/esm/ios/infoPlist.js.map +1 -1
- package/expo-plugin/build/esm/ios/serverUpdate.js +21 -0
- package/expo-plugin/build/esm/ios/serverUpdate.js.map +1 -0
- package/expo-plugin/build/esm/ios-widget/clientRendered.js +5 -5
- package/expo-plugin/build/esm/ios-widget/clientRendered.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js +6 -6
- package/expo-plugin/build/esm/ios-widget/clientRenderedPrerender.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/dynamic-live-activity/swift.js +122 -0
- package/expo-plugin/build/esm/ios-widget/dynamic-live-activity/swift.js.map +1 -0
- package/expo-plugin/build/esm/ios-widget/files/index.js +10 -3
- package/expo-plugin/build/esm/ios-widget/files/index.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/files/infoPlist.js +6 -3
- package/expo-plugin/build/esm/ios-widget/files/infoPlist.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/files/manifest.js +23 -1
- package/expo-plugin/build/esm/ios-widget/files/manifest.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/files/swift-utils.js +4 -0
- package/expo-plugin/build/esm/ios-widget/files/swift-utils.js.map +1 -0
- package/expo-plugin/build/esm/ios-widget/files/swift.js +50 -20
- package/expo-plugin/build/esm/ios-widget/files/swift.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/index.js +9 -3
- package/expo-plugin/build/esm/ios-widget/index.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/podfile.js +36 -11
- package/expo-plugin/build/esm/ios-widget/podfile.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/widgetPlist.js +34 -22
- package/expo-plugin/build/esm/ios-widget/widgetPlist.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js +194 -117
- package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/xcode/configurationList.js +31 -22
- package/expo-plugin/build/esm/ios-widget/xcode/configurationList.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/xcode/fileReferences.js +45 -0
- package/expo-plugin/build/esm/ios-widget/xcode/fileReferences.js.map +1 -0
- package/expo-plugin/build/esm/ios-widget/xcode/groups.js +45 -42
- package/expo-plugin/build/esm/ios-widget/xcode/groups.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/xcode/index.js +97 -96
- package/expo-plugin/build/esm/ios-widget/xcode/index.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/xcode/mainAppSettings.js +41 -39
- package/expo-plugin/build/esm/ios-widget/xcode/mainAppSettings.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/xcode/productFile.js +3 -2
- package/expo-plugin/build/esm/ios-widget/xcode/productFile.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/xcode/target.js +28 -62
- package/expo-plugin/build/esm/ios-widget/xcode/target.js.map +1 -1
- package/expo-plugin/build/esm/utils/urlScheme.js +34 -14
- package/expo-plugin/build/esm/utils/urlScheme.js.map +1 -1
- package/expo-plugin/build/esm/validation.js +54 -1
- package/expo-plugin/build/esm/validation.js.map +1 -1
- package/expo-plugin/build/types/constants.d.ts +10 -1
- package/expo-plugin/build/types/constants.d.ts.map +1 -1
- package/expo-plugin/build/types/index.d.ts +1 -1
- package/expo-plugin/build/types/index.d.ts.map +1 -1
- package/expo-plugin/build/types/ios/index.d.ts +2 -0
- package/expo-plugin/build/types/ios/index.d.ts.map +1 -1
- package/expo-plugin/build/types/ios/infoPlist.d.ts +2 -0
- package/expo-plugin/build/types/ios/infoPlist.d.ts.map +1 -1
- package/expo-plugin/build/types/ios/serverUpdate.d.ts +11 -0
- package/expo-plugin/build/types/ios/serverUpdate.d.ts.map +1 -0
- package/expo-plugin/build/types/ios-widget/clientRendered.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/clientRenderedPrerender.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/dynamic-live-activity/swift.d.ts +7 -0
- package/expo-plugin/build/types/ios-widget/dynamic-live-activity/swift.d.ts.map +1 -0
- package/expo-plugin/build/types/ios-widget/files/index.d.ts +3 -1
- package/expo-plugin/build/types/ios-widget/files/index.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/files/infoPlist.d.ts +2 -1
- package/expo-plugin/build/types/ios-widget/files/infoPlist.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/files/manifest.d.ts +9 -2
- package/expo-plugin/build/types/ios-widget/files/manifest.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/files/swift-utils.d.ts +2 -0
- package/expo-plugin/build/types/ios-widget/files/swift-utils.d.ts.map +1 -0
- package/expo-plugin/build/types/ios-widget/files/swift.d.ts +6 -2
- package/expo-plugin/build/types/ios-widget/files/swift.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/index.d.ts +3 -1
- package/expo-plugin/build/types/ios-widget/index.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/podfile.d.ts +19 -1
- package/expo-plugin/build/types/ios-widget/podfile.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/widgetPlist.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts +6 -8
- package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/xcode/configurationList.d.ts +15 -10
- package/expo-plugin/build/types/ios-widget/xcode/configurationList.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/xcode/fileReferences.d.ts +16 -0
- package/expo-plugin/build/types/ios-widget/xcode/fileReferences.d.ts.map +1 -0
- package/expo-plugin/build/types/ios-widget/xcode/groups.d.ts +0 -4
- package/expo-plugin/build/types/ios-widget/xcode/groups.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/xcode/index.d.ts +23 -3
- package/expo-plugin/build/types/ios-widget/xcode/index.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/xcode/mainAppSettings.d.ts +16 -4
- package/expo-plugin/build/types/ios-widget/xcode/mainAppSettings.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/xcode/productFile.d.ts +0 -4
- package/expo-plugin/build/types/ios-widget/xcode/productFile.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/xcode/target.d.ts +11 -26
- package/expo-plugin/build/types/ios-widget/xcode/target.d.ts.map +1 -1
- package/expo-plugin/build/types/types.d.ts +23 -4
- package/expo-plugin/build/types/types.d.ts.map +1 -1
- package/expo-plugin/build/types/utils/urlScheme.d.ts +16 -0
- package/expo-plugin/build/types/utils/urlScheme.d.ts.map +1 -1
- package/expo-plugin/build/types/validation.d.ts +2 -1
- package/expo-plugin/build/types/validation.d.ts.map +1 -1
- package/expo-plugin/src/constants.ts +24 -1
- package/expo-plugin/src/index.ts +14 -1
- package/expo-plugin/src/ios/index.ts +3 -0
- package/expo-plugin/src/ios/infoPlist.ts +33 -5
- package/expo-plugin/src/ios/serverUpdate.ts +29 -0
- package/expo-plugin/src/ios-widget/clientRendered.ts +9 -5
- package/expo-plugin/src/ios-widget/clientRenderedPrerender.ts +11 -6
- package/expo-plugin/src/ios-widget/dynamic-live-activity/swift.ts +138 -0
- package/expo-plugin/src/ios-widget/files/index.ts +15 -4
- package/expo-plugin/src/ios-widget/files/infoPlist.ts +17 -3
- package/expo-plugin/src/ios-widget/files/manifest.ts +42 -2
- package/expo-plugin/src/ios-widget/files/swift-utils.ts +3 -0
- package/expo-plugin/src/ios-widget/files/swift.ts +69 -22
- package/expo-plugin/src/ios-widget/index.ts +13 -3
- package/expo-plugin/src/ios-widget/podfile.ts +46 -13
- package/expo-plugin/src/ios-widget/widgetPlist.ts +37 -23
- package/expo-plugin/src/ios-widget/xcode/__fixtures__/fresh.pbxproj +197 -0
- package/expo-plugin/src/ios-widget/xcode/__fixtures__/with-existing-extension.pbxproj +321 -0
- package/expo-plugin/src/ios-widget/xcode/__fixtures__/with-per-config-signing.pbxproj +203 -0
- package/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap +363 -0
- package/expo-plugin/src/ios-widget/xcode/__tests__/pbxConsistency.ts +130 -0
- package/expo-plugin/src/ios-widget/xcode/buildPhases.ts +210 -164
- package/expo-plugin/src/ios-widget/xcode/configurationList.ts +52 -35
- package/expo-plugin/src/ios-widget/xcode/fileReferences.ts +51 -0
- package/expo-plugin/src/ios-widget/xcode/groups.ts +52 -54
- package/expo-plugin/src/ios-widget/xcode/index.ts +124 -119
- package/expo-plugin/src/ios-widget/xcode/mainAppSettings.ts +59 -54
- package/expo-plugin/src/ios-widget/xcode/productFile.ts +3 -2
- package/expo-plugin/src/ios-widget/xcode/target.ts +31 -83
- package/expo-plugin/src/types.ts +28 -4
- package/expo-plugin/src/utils/urlScheme.ts +47 -17
- package/expo-plugin/src/validation.ts +76 -1
- package/ios/Package.swift +39 -4
- package/ios/VoltraWidget.podspec +1 -0
- package/ios/app/NativeVoltra.mm +126 -7
- package/ios/app/VoltraLiveActivityManager.swift +30 -0
- package/ios/app/VoltraLiveActivityService.swift +77 -5
- package/ios/app/VoltraModule.swift +117 -1
- package/ios/app/VoltraModuleImpl.swift +116 -2
- package/ios/app/VoltraViewComponentView.mm +3 -3
- package/ios/app/VoltraWidgetService.swift +147 -12
- package/ios/app/dynamic-live-activity/VoltraDynamicLiveActivityService.swift +97 -0
- package/ios/app/dynamic-live-activity/VoltraLiveActivityChronology.swift +68 -0
- package/ios/shared/DynamicWidgetPropsCodec.swift +51 -0
- package/ios/shared/DynamicWidgetPropsStorage.swift +53 -0
- package/ios/shared/DynamicWidgetPropsStore.swift +117 -0
- package/ios/shared/DynamicWidgetRenderCoordinator.swift +31 -0
- package/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerFetchResolver.swift +34 -0
- package/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerProps.swift +55 -0
- package/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerPropsStore.swift +146 -0
- package/ios/shared/DynamicWidgetServerUpdate/DynamicWidgetServerUpdateRunner.swift +193 -0
- package/ios/shared/DynamicWidgetUpdater.swift +30 -0
- package/ios/shared/ServerWidgetContentResolver.swift +60 -0
- package/ios/shared/ServerWidgetResponseStore.swift +25 -0
- package/ios/shared/ShortNames.swift +6 -0
- package/ios/shared/VoltraConfig.swift +41 -0
- package/ios/shared/VoltraConstants.swift +19 -0
- package/ios/shared/VoltraElement.swift +127 -43
- package/ios/shared/VoltraEventBus.swift +52 -7
- package/ios/shared/VoltraJSRenderer.swift +93 -23
- package/ios/shared/VoltraNode.swift +7 -111
- package/ios/shared/VoltraRefreshIntent.swift +1 -1
- package/ios/shared/VoltraWidgetDefaults.swift +9 -1
- package/ios/shared/VoltraWidgetKind.swift +41 -0
- package/ios/shared/VoltraWidgetServerFetcher.swift +52 -107
- package/ios/shared/WidgetServer/VoltraWidgetAppearance.swift +17 -0
- package/ios/shared/WidgetServer/VoltraWidgetServer.swift +138 -0
- package/ios/shared/WidgetServer/WidgetCanonicalConfiguration.swift +77 -0
- package/ios/shared/WidgetServer/WidgetScope.swift +55 -0
- package/ios/shared/WidgetServer/WidgetServerEtagStore.swift +55 -0
- package/ios/shared/WidgetServer/WidgetServerFetcher.swift +179 -0
- package/ios/shared/WidgetServer/WidgetServerRequestBuilder.swift +114 -0
- package/ios/shared/WidgetServer/WidgetServerSettingsCodec.swift +47 -0
- package/ios/shared/WidgetServer/WidgetServerSettingsResolver.swift +129 -0
- package/ios/shared/WidgetServer/WidgetServerSettingsStore.swift +170 -0
- package/ios/shared/WidgetServer/WidgetServerSettingsValidator.swift +73 -0
- package/ios/shared/WidgetServer/WidgetServerUpdateSettings.swift +123 -0
- package/ios/shared/WidgetServer/WidgetServerUpdateSettingsJson.swift +115 -0
- package/ios/shared/dynamic-live-activity/DynamicLiveActivityPropsCodec.swift +13 -0
- package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityBundleSource.swift +156 -0
- package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityObserver.swift +97 -0
- package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityOperations.swift +105 -0
- package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityPayloadValidator.swift +55 -0
- package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRegistry.swift +143 -0
- package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureQueue.swift +158 -0
- package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderFailureReporter.swift +120 -0
- package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityRenderer.swift +370 -0
- package/ios/shared/dynamic-live-activity/VoltraDynamicLiveActivityTypes.swift +87 -0
- package/ios/shared/dynamic-live-activity/VoltraLiveActivityOrder.swift +19 -0
- package/ios/target/VoltraClientWidgetRuntime.swift +60 -10
- package/ios/target/VoltraDynamicWidgetServerUpdateProvider.swift +204 -0
- package/ios/target/VoltraHomeWidget.swift +91 -92
- package/ios/target/VoltraWidget.swift +3 -1
- package/ios/ui/Extensions/VoltraNode+View.swift +113 -0
- package/ios/ui/Generated/Parameters/ChartParameters.swift +3 -0
- package/ios/ui/Helpers/VoltraDeepLinkResolver.swift +5 -3
- package/ios/ui/Helpers/VoltraProgressDriver.swift +6 -2
- package/ios/ui/Layout/VoltraFlexStackLayout.swift +9 -3
- package/ios/ui/Style/JSColorParser.swift +24 -8
- package/ios/ui/Style/JSGradientParser.swift +36 -12
- package/ios/ui/Style/JSStyleParser.swift +36 -12
- package/ios/ui/Style/LayoutStyle.swift +12 -4
- package/ios/ui/Views/VoltraChart.swift +160 -16
- package/ios/ui/Views/VoltraLinearGradient.swift +6 -2
- package/package.json +5 -5
- package/src/dynamic-widget/api.ts +25 -0
- package/src/events.ts +34 -0
- package/src/index.ts +23 -0
- package/src/live-activity/api.ts +2 -2
- package/src/live-activity/dynamic-api.ts +240 -0
- package/src/native/NativeVoltra.ts +22 -0
- package/src/types.ts +4 -0
- package/src/utils/enableDynamicLiveActivityHotReload.ts +50 -0
- package/src/utils/enableWidgetHotReload.ts +9 -16
- package/src/utils/useUpdateOnHMR.ts +3 -12
- package/src/widgets/server-credentials.ts +8 -0
- package/src/widgets/server-update.ts +84 -0
- /package/ios/shared/{VoltraKeychainHelper.swift → WidgetServer/VoltraKeychainHelper.swift} +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Versioned, per-ID Dynamic Widget props shared by the app and widget extension processes.
|
|
4
|
+
struct DynamicWidgetPropsStore {
|
|
5
|
+
private let storage: Result<any DynamicWidgetPropsStorage, Error>
|
|
6
|
+
|
|
7
|
+
init() {
|
|
8
|
+
guard let groupIdentifier = VoltraConfig.groupIdentifier() else {
|
|
9
|
+
storage = .failure(DynamicWidgetPropsStoreError.appGroupNotConfigured)
|
|
10
|
+
return
|
|
11
|
+
}
|
|
12
|
+
guard let userDefaults = UserDefaults(suiteName: groupIdentifier) else {
|
|
13
|
+
storage = .failure(DynamicWidgetPropsStoreError.userDefaultsUnavailable)
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
storage = .success(DynamicWidgetPropsUserDefaultsStorage(userDefaults: userDefaults))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
init(storage: any DynamicWidgetPropsStorage) {
|
|
20
|
+
self.storage = .success(storage)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// The type-level (widget-scoped) write path — the app's `updateDynamicWidget`.
|
|
24
|
+
func persistDynamicWidgetProps(_ dynamicWidgetPropsJSON: String, for dynamicWidgetID: String) throws {
|
|
25
|
+
try persist(dynamicWidgetPropsJSON, storageKey: Self.storageKey(for: dynamicWidgetID))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// The instance write path — a committed per-instance server fetch (ADR 0007).
|
|
29
|
+
func persistInstanceDynamicWidgetProps(_ dynamicWidgetPropsJSON: String, for scope: WidgetScope) throws {
|
|
30
|
+
guard case let .instance(widgetId, key) = scope else {
|
|
31
|
+
try persistDynamicWidgetProps(dynamicWidgetPropsJSON, for: scope.widgetId)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
try persist(dynamicWidgetPropsJSON, storageKey: VoltraStorageKeys.dynamicWidgetInstancePropsV1(scope.storageKey))
|
|
35
|
+
try addInstanceKeyToIndex(key, for: widgetId)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private func persist(_ dynamicWidgetPropsJSON: String, storageKey: String) throws {
|
|
39
|
+
let storageEntryJSON = try DynamicWidgetPropsCodec.encodeStorageEntry(
|
|
40
|
+
dynamicWidgetPropsJSON: dynamicWidgetPropsJSON
|
|
41
|
+
)
|
|
42
|
+
let resolvedStorage = try storage.get()
|
|
43
|
+
try resolvedStorage.set(storageEntryJSON, forKey: storageKey)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// The type-level props only, with no instance fallback — used by the trial render.
|
|
47
|
+
func dynamicWidgetProps(for dynamicWidgetID: String) -> String {
|
|
48
|
+
read(storageKey: Self.storageKey(for: dynamicWidgetID)) ?? DynamicWidgetPropsCodec.emptyDynamicWidgetPropsJSON
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// Props for one placement: the instance slot for `scope`'s key, falling back to the widget slot
|
|
52
|
+
/// when the instance has not fetched yet or has no configuration (ADR 0007).
|
|
53
|
+
func dynamicWidgetProps(for scope: WidgetScope) -> String {
|
|
54
|
+
switch scope {
|
|
55
|
+
case let .widget(id):
|
|
56
|
+
return dynamicWidgetProps(for: id)
|
|
57
|
+
case .instance:
|
|
58
|
+
if let instanceProps = read(storageKey: VoltraStorageKeys.dynamicWidgetInstancePropsV1(scope.storageKey)) {
|
|
59
|
+
return instanceProps
|
|
60
|
+
}
|
|
61
|
+
return dynamicWidgetProps(for: scope.widgetId)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private func read(storageKey: String) -> String? {
|
|
66
|
+
guard case let .success(resolvedStorage) = storage else { return nil }
|
|
67
|
+
guard let storageEntryJSON = (try? resolvedStorage.string(forKey: storageKey)) ?? nil else {
|
|
68
|
+
return nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return DynamicWidgetPropsCodec.decodeDynamicWidgetProps(storageEntryJSON: storageEntryJSON)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Clears the widget-level props and every instance slot of `dynamicWidgetID` (ADR 0007):
|
|
75
|
+
/// `clearWidget` and logout are meant to reset a widget entirely, not leave stale per-instance
|
|
76
|
+
/// data behind that a fallback would never reveal again.
|
|
77
|
+
func clearDynamicWidgetProps(for dynamicWidgetID: String) throws {
|
|
78
|
+
let resolvedStorage = try storage.get()
|
|
79
|
+
let instanceKeys = instanceKeys(for: dynamicWidgetID)
|
|
80
|
+
|
|
81
|
+
try resolvedStorage.removeObject(forKey: Self.storageKey(for: dynamicWidgetID))
|
|
82
|
+
try resolvedStorage.removeObject(forKey: VoltraStorageKeys.dynamicWidgetPropsInstanceIndexV1(dynamicWidgetID))
|
|
83
|
+
for key in instanceKeys {
|
|
84
|
+
let instanceStorageKey = VoltraStorageKeys.dynamicWidgetInstancePropsV1("\(dynamicWidgetID)#\(key)")
|
|
85
|
+
try resolvedStorage.removeObject(forKey: instanceStorageKey)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// The instance keys `dynamicWidgetID` has ever fetched into, so callers can clear per-instance
|
|
90
|
+
/// state that lives in other stores (status, ETag).
|
|
91
|
+
func instanceKeys(for dynamicWidgetID: String) -> Set<String> {
|
|
92
|
+
guard
|
|
93
|
+
case let .success(resolvedStorage) = storage,
|
|
94
|
+
let raw = try? resolvedStorage.string(forKey: VoltraStorageKeys.dynamicWidgetPropsInstanceIndexV1(dynamicWidgetID)) ?? nil,
|
|
95
|
+
let data = raw.data(using: .utf8),
|
|
96
|
+
let keys = try? JSONDecoder().decode([String].self, from: data)
|
|
97
|
+
else {
|
|
98
|
+
return []
|
|
99
|
+
}
|
|
100
|
+
return Set(keys)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private func addInstanceKeyToIndex(_ key: String, for dynamicWidgetID: String) throws {
|
|
104
|
+
var current = instanceKeys(for: dynamicWidgetID)
|
|
105
|
+
guard !current.contains(key) else { return }
|
|
106
|
+
current.insert(key)
|
|
107
|
+
|
|
108
|
+
let resolvedStorage = try storage.get()
|
|
109
|
+
let data = try JSONEncoder().encode(current.sorted())
|
|
110
|
+
guard let json = String(data: data, encoding: .utf8) else { return }
|
|
111
|
+
try resolvedStorage.set(json, forKey: VoltraStorageKeys.dynamicWidgetPropsInstanceIndexV1(dynamicWidgetID))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
static func storageKey(for dynamicWidgetID: String) -> String {
|
|
115
|
+
VoltraStorageKeys.dynamicWidgetPropsV1(dynamicWidgetID)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/// Routes a Dynamic Widget's current persisted props and environment to its runtime boundary.
|
|
2
|
+
struct DynamicWidgetRenderCoordinator {
|
|
3
|
+
typealias DynamicWidgetPropsProvider = (_ dynamicWidgetID: String) -> String
|
|
4
|
+
typealias DynamicWidgetRuntimeBoundary = (
|
|
5
|
+
_ dynamicWidgetID: String,
|
|
6
|
+
_ dynamicWidgetPropsJSON: String,
|
|
7
|
+
_ dynamicWidgetEnvironmentJSON: String
|
|
8
|
+
) -> String?
|
|
9
|
+
|
|
10
|
+
private let dynamicWidgetPropsProvider: DynamicWidgetPropsProvider
|
|
11
|
+
private let dynamicWidgetRuntimeBoundary: DynamicWidgetRuntimeBoundary
|
|
12
|
+
|
|
13
|
+
init(
|
|
14
|
+
dynamicWidgetPropsProvider: @escaping DynamicWidgetPropsProvider,
|
|
15
|
+
dynamicWidgetRuntimeBoundary: @escaping DynamicWidgetRuntimeBoundary
|
|
16
|
+
) {
|
|
17
|
+
self.dynamicWidgetPropsProvider = dynamicWidgetPropsProvider
|
|
18
|
+
self.dynamicWidgetRuntimeBoundary = dynamicWidgetRuntimeBoundary
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func renderDynamicWidget(
|
|
22
|
+
dynamicWidgetID: String,
|
|
23
|
+
dynamicWidgetEnvironmentJSON: String
|
|
24
|
+
) -> String? {
|
|
25
|
+
dynamicWidgetRuntimeBoundary(
|
|
26
|
+
dynamicWidgetID,
|
|
27
|
+
dynamicWidgetPropsProvider(dynamicWidgetID),
|
|
28
|
+
dynamicWidgetEnvironmentJSON
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Collapses a burst of timeline requests for one scope into a single fetch.
|
|
4
|
+
///
|
|
5
|
+
/// WidgetKit asks for a timeline once per widget instance per reload, and tapping the refresh
|
|
6
|
+
/// button produces two reloads for one tap (the intent's own reload plus the automatic
|
|
7
|
+
/// post-intent one). Without this, a widget placed twice in two sizes would fetch four times for
|
|
8
|
+
/// what the user experienced as one refresh — and the reload budget is shared across every widget
|
|
9
|
+
/// in the app.
|
|
10
|
+
///
|
|
11
|
+
/// The payload engine already coalesces this way; this is the same window, keyed by scope so it
|
|
12
|
+
/// keeps working when instances arrive.
|
|
13
|
+
public actor DynamicWidgetServerFetchCoordinator {
|
|
14
|
+
public static let defaultCoalesceInterval: TimeInterval = 3
|
|
15
|
+
|
|
16
|
+
public static let shared = DynamicWidgetServerFetchCoordinator()
|
|
17
|
+
|
|
18
|
+
private var lastRun: [WidgetScope: Date] = [:]
|
|
19
|
+
private let coalesceInterval: TimeInterval
|
|
20
|
+
|
|
21
|
+
public init(coalesceInterval: TimeInterval = DynamicWidgetServerFetchCoordinator.defaultCoalesceInterval) {
|
|
22
|
+
self.coalesceInterval = coalesceInterval
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Whether this request should fetch, or ride on one that just happened.
|
|
26
|
+
public func shouldFetch(_ scope: WidgetScope, now: Date = Date()) -> Bool {
|
|
27
|
+
if let last = lastRun[scope], now.timeIntervalSince(last) < coalesceInterval {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
lastRun[scope] = now
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// What a `200` body turned out to be.
|
|
4
|
+
public enum DynamicWidgetPropsParseResult: Equatable {
|
|
5
|
+
case props(String)
|
|
6
|
+
case invalid(reason: String)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/// Reads a server response as Dynamic Widget props.
|
|
10
|
+
///
|
|
11
|
+
/// The whole point of ADR 0002 is that the server returns data, not UI, so the only thing accepted
|
|
12
|
+
/// here is a JSON object. The one shape called out specially is a Voltra payload: `serverUpdate`
|
|
13
|
+
/// is the same config key for both engines, so pointing a widget with an `entry` at a payload
|
|
14
|
+
/// endpoint is the easy mistake to make, and it has to fail loudly rather than look like unusable
|
|
15
|
+
/// props.
|
|
16
|
+
public enum DynamicWidgetServerProps {
|
|
17
|
+
public static func parse(_ body: Data) -> DynamicWidgetPropsParseResult {
|
|
18
|
+
guard !body.isEmpty else {
|
|
19
|
+
return .invalid(reason: "response body was empty")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
guard let parsed = try? JSONSerialization.jsonObject(with: body) else {
|
|
23
|
+
return .invalid(reason: "response body is not JSON; a Dynamic Widget's props must be a JSON object")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
guard let object = parsed as? [String: Any] else {
|
|
27
|
+
return .invalid(reason: "response body is not a JSON object; a Dynamic Widget's props must be a JSON object")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if looksLikeVoltraPayload(object) {
|
|
31
|
+
return .invalid(reason: """
|
|
32
|
+
response body looks like a Voltra payload (top-level 'v' with 'variants' or 'e'). This widget \
|
|
33
|
+
has an entry, so it renders on the device: return the props it should render, not a rendered \
|
|
34
|
+
payload.
|
|
35
|
+
""")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
guard let normalized = try? JSONSerialization.data(withJSONObject: object),
|
|
39
|
+
let json = String(data: normalized, encoding: .utf8)
|
|
40
|
+
else {
|
|
41
|
+
return .invalid(reason: "response body could not be re-encoded as props")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return .props(json)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// A Voltra payload always carries a version under `v` alongside either the size variants a
|
|
48
|
+
/// widget renders or the shared element table. Props that happen to have a `v` key are not
|
|
49
|
+
/// mistaken for one.
|
|
50
|
+
private static func looksLikeVoltraPayload(_ body: [String: Any]) -> Bool {
|
|
51
|
+
guard body["v"] is Int else { return false }
|
|
52
|
+
|
|
53
|
+
return body["variants"] is [String: Any] || body["e"] is [Any]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// What the widget is told about the server side of its props, as `env.serverUpdate`.
|
|
4
|
+
///
|
|
5
|
+
/// Deliberately not the props themselves: fetched props are committed to the Dynamic Widget's
|
|
6
|
+
/// existing props slot, so the render path cannot tell whether they came from a fetch or from
|
|
7
|
+
/// `updateDynamicWidget`. This record is only the story around them.
|
|
8
|
+
public struct DynamicWidgetServerStatus: Equatable, Codable {
|
|
9
|
+
public static let fresh = "fresh"
|
|
10
|
+
public static let stale = "stale"
|
|
11
|
+
public static let never = "never"
|
|
12
|
+
public static let disabled = "disabled"
|
|
13
|
+
|
|
14
|
+
public static let errorNetwork = "network"
|
|
15
|
+
public static let errorHttp = "http"
|
|
16
|
+
public static let errorUnauthorized = "unauthorized"
|
|
17
|
+
public static let errorParse = "parse"
|
|
18
|
+
public static let errorRender = "render"
|
|
19
|
+
|
|
20
|
+
public let status: String
|
|
21
|
+
public let fetchedAt: Int?
|
|
22
|
+
public let error: String?
|
|
23
|
+
public let httpStatus: Int?
|
|
24
|
+
|
|
25
|
+
public init(status: String, fetchedAt: Int? = nil, error: String? = nil, httpStatus: Int? = nil) {
|
|
26
|
+
self.status = status
|
|
27
|
+
self.fetchedAt = fetchedAt
|
|
28
|
+
self.error = error
|
|
29
|
+
self.httpStatus = httpStatus
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// What a widget sees before any fetch has succeeded.
|
|
33
|
+
public static let neverFetched = DynamicWidgetServerStatus(status: never)
|
|
34
|
+
|
|
35
|
+
public func toJSON() -> String {
|
|
36
|
+
var fields = ["\"status\": \(jsonString(status))"]
|
|
37
|
+
|
|
38
|
+
fetchedAt.map { fields.append("\"fetchedAt\": \($0)") }
|
|
39
|
+
error.map { fields.append("\"error\": \(jsonString($0))") }
|
|
40
|
+
httpStatus.map { fields.append("\"httpStatus\": \($0)") }
|
|
41
|
+
|
|
42
|
+
return "{ \(fields.joined(separator: ", ")) }"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private func jsonString(_ value: String) -> String {
|
|
46
|
+
let escaped = value
|
|
47
|
+
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
48
|
+
.replacingOccurrences(of: "\"", with: "\\\"")
|
|
49
|
+
|
|
50
|
+
return "\"\(escaped)\""
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Per-scope record of how the last fetch went, in the App Group so the app and the widget
|
|
55
|
+
/// extension agree on it.
|
|
56
|
+
///
|
|
57
|
+
/// Kept apart from the props themselves so that clearing a widget's props — logout,
|
|
58
|
+
/// `clearWidget` — and clearing its fetch history stay separate decisions.
|
|
59
|
+
public struct DynamicWidgetServerPropsStore {
|
|
60
|
+
private let defaults: UserDefaults?
|
|
61
|
+
|
|
62
|
+
public init(defaults: UserDefaults? = VoltraConfig.groupIdentifier().flatMap { UserDefaults(suiteName: $0) }) {
|
|
63
|
+
self.defaults = defaults
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public func status(for scope: WidgetScope) -> DynamicWidgetServerStatus {
|
|
67
|
+
guard let data = defaults?.data(forKey: key(scope)),
|
|
68
|
+
let status = try? JSONDecoder().decode(DynamicWidgetServerStatus.self, from: data)
|
|
69
|
+
else {
|
|
70
|
+
return .neverFetched
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return status
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public func put(_ status: DynamicWidgetServerStatus, for scope: WidgetScope) {
|
|
77
|
+
guard let data = try? JSONEncoder().encode(status) else { return }
|
|
78
|
+
|
|
79
|
+
defaults?.set(data, forKey: key(scope))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Records a failure without losing the fact that a fetch once worked. `stale` is only
|
|
83
|
+
/// meaningful next to the `fetchedAt` of the last success, so that is carried forward.
|
|
84
|
+
public func recordFailure(_ error: String, httpStatus: Int? = nil, for scope: WidgetScope) {
|
|
85
|
+
let previous = status(for: scope)
|
|
86
|
+
|
|
87
|
+
put(
|
|
88
|
+
DynamicWidgetServerStatus(
|
|
89
|
+
status: previous.fetchedAt == nil ? DynamicWidgetServerStatus.never : DynamicWidgetServerStatus.stale,
|
|
90
|
+
fetchedAt: previous.fetchedAt,
|
|
91
|
+
error: error,
|
|
92
|
+
httpStatus: httpStatus
|
|
93
|
+
),
|
|
94
|
+
for: scope
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public func recordSuccess(fetchedAt: Int, httpStatus: Int, for scope: WidgetScope) {
|
|
99
|
+
put(
|
|
100
|
+
DynamicWidgetServerStatus(status: DynamicWidgetServerStatus.fresh, fetchedAt: fetchedAt, httpStatus: httpStatus),
|
|
101
|
+
for: scope
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Reports `disabled` once, keeping the last `fetchedAt` so a widget that comes back under app
|
|
106
|
+
/// control can still say when the server last spoke.
|
|
107
|
+
public func markDisabledIfNeeded(enabled: Bool, for scope: WidgetScope) {
|
|
108
|
+
guard !enabled else { return }
|
|
109
|
+
|
|
110
|
+
let previous = status(for: scope)
|
|
111
|
+
|
|
112
|
+
guard previous.status != DynamicWidgetServerStatus.disabled else { return }
|
|
113
|
+
|
|
114
|
+
put(
|
|
115
|
+
DynamicWidgetServerStatus(status: DynamicWidgetServerStatus.disabled, fetchedAt: previous.fetchedAt),
|
|
116
|
+
for: scope
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// Records that the app just wrote props itself, so the reload that write triggers renders them
|
|
121
|
+
/// instead of racing a fetch that would immediately overwrite them.
|
|
122
|
+
public func noteAppWrite(for scope: WidgetScope, at date: Date = Date()) {
|
|
123
|
+
defaults?.set(date.timeIntervalSince1970, forKey: appWriteKey(scope))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public func appWriteAt(for scope: WidgetScope) -> Date? {
|
|
127
|
+
guard let seconds = defaults?.object(forKey: appWriteKey(scope)) as? TimeInterval else {
|
|
128
|
+
return nil
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return Date(timeIntervalSince1970: seconds)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public func clear(_ scope: WidgetScope) {
|
|
135
|
+
defaults?.removeObject(forKey: key(scope))
|
|
136
|
+
defaults?.removeObject(forKey: appWriteKey(scope))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private func key(_ scope: WidgetScope) -> String {
|
|
140
|
+
"Voltra_DynamicWidgetServer_v1_\(scope.storageKey)"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private func appWriteKey(_ scope: WidgetScope) -> String {
|
|
144
|
+
"Voltra_DynamicWidgetServer_AppWrite_v1_\(scope.storageKey)"
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// What one server-update run decided, before it becomes a timeline.
|
|
4
|
+
public enum DynamicWidgetServerUpdateOutcome: Equatable {
|
|
5
|
+
/// Props were committed, or the server said `304` and what we have is still current.
|
|
6
|
+
case committed
|
|
7
|
+
/// Nothing to do: the widget has no URL, the app turned fetching off, or another request in the
|
|
8
|
+
/// same burst just fetched.
|
|
9
|
+
case skipped
|
|
10
|
+
/// The fetch failed in a way that waiting could fix. Previous props stay on screen.
|
|
11
|
+
case retry
|
|
12
|
+
/// The fetch or the response failed in a way waiting will not fix. Previous props stay.
|
|
13
|
+
case failed
|
|
14
|
+
/// The settings the response was built from are no longer current, so it was dropped.
|
|
15
|
+
case dropped
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// The outcome plus what the server asked about the next fetch.
|
|
19
|
+
public struct DynamicWidgetServerUpdateResult: Equatable {
|
|
20
|
+
public let outcome: DynamicWidgetServerUpdateOutcome
|
|
21
|
+
/// `Cache-Control: max-age` on a success, `Retry-After` on a `429` or `503`, in minutes and
|
|
22
|
+
/// already clamped to what WidgetKit can honour. Nil when the server said nothing and the
|
|
23
|
+
/// widget's own interval stands.
|
|
24
|
+
public let nextIntervalMinutes: Int?
|
|
25
|
+
|
|
26
|
+
public init(_ outcome: DynamicWidgetServerUpdateOutcome, nextIntervalMinutes: Int? = nil) {
|
|
27
|
+
self.outcome = outcome
|
|
28
|
+
self.nextIntervalMinutes = nextIntervalMinutes
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Fetch, parse, trial-render, commit — the four steps ADR 0002 requires of every server-driven
|
|
33
|
+
/// widget, with every collaborator injected so the failure table can be tested without a network,
|
|
34
|
+
/// a JS runtime, or WidgetKit.
|
|
35
|
+
///
|
|
36
|
+
/// The rule the ordering exists for: props that do not render are never committed. A server that
|
|
37
|
+
/// starts returning a shape the widget throws on leaves the last good props on screen instead of
|
|
38
|
+
/// replacing them with a blank tile.
|
|
39
|
+
public struct DynamicWidgetServerUpdateRunner {
|
|
40
|
+
public typealias Fetch = (WidgetScope, ResolvedWidgetServerSettings, String?) async -> WidgetServerFetchResult
|
|
41
|
+
|
|
42
|
+
private let resolveSettings: (WidgetScope) -> ResolvedWidgetServerSettings
|
|
43
|
+
private let currentRevision: (WidgetScope) -> Int
|
|
44
|
+
private let readEtag: (WidgetScope, String?) -> String?
|
|
45
|
+
private let fetch: Fetch
|
|
46
|
+
private let writeEtag: (WidgetScope, String, String?) -> Void
|
|
47
|
+
private let trialRender: (WidgetScope, String) -> Bool
|
|
48
|
+
private let commitProps: (WidgetScope, String) throws -> Void
|
|
49
|
+
private let recordSuccess: (WidgetScope, Int, Int) -> Void
|
|
50
|
+
private let recordFailure: (WidgetScope, String, Int?) -> Void
|
|
51
|
+
private let markDisabled: (WidgetScope, Bool) -> Void
|
|
52
|
+
private let now: () -> Date
|
|
53
|
+
|
|
54
|
+
public init(
|
|
55
|
+
resolveSettings: @escaping (WidgetScope) -> ResolvedWidgetServerSettings,
|
|
56
|
+
currentRevision: @escaping (WidgetScope) -> Int,
|
|
57
|
+
readEtag: @escaping (WidgetScope, String?) -> String?,
|
|
58
|
+
fetch: @escaping Fetch,
|
|
59
|
+
writeEtag: @escaping (WidgetScope, String, String?) -> Void,
|
|
60
|
+
trialRender: @escaping (WidgetScope, String) -> Bool,
|
|
61
|
+
commitProps: @escaping (WidgetScope, String) throws -> Void,
|
|
62
|
+
recordSuccess: @escaping (WidgetScope, Int, Int) -> Void,
|
|
63
|
+
recordFailure: @escaping (WidgetScope, String, Int?) -> Void,
|
|
64
|
+
markDisabled: @escaping (WidgetScope, Bool) -> Void,
|
|
65
|
+
now: @escaping () -> Date = Date.init
|
|
66
|
+
) {
|
|
67
|
+
self.resolveSettings = resolveSettings
|
|
68
|
+
self.currentRevision = currentRevision
|
|
69
|
+
self.readEtag = readEtag
|
|
70
|
+
self.fetch = fetch
|
|
71
|
+
self.writeEtag = writeEtag
|
|
72
|
+
self.trialRender = trialRender
|
|
73
|
+
self.commitProps = commitProps
|
|
74
|
+
self.recordSuccess = recordSuccess
|
|
75
|
+
self.recordFailure = recordFailure
|
|
76
|
+
self.markDisabled = markDisabled
|
|
77
|
+
self.now = now
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public func run(_ scope: WidgetScope) async -> DynamicWidgetServerUpdateResult {
|
|
81
|
+
let settings = resolveSettings(scope)
|
|
82
|
+
|
|
83
|
+
guard settings.shouldFetch, let url = settings.url else {
|
|
84
|
+
markDisabled(scope, settings.enabled)
|
|
85
|
+
return DynamicWidgetServerUpdateResult(.skipped)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let revision = currentRevision(scope)
|
|
89
|
+
let result = await fetch(scope, settings, readEtag(scope, url))
|
|
90
|
+
|
|
91
|
+
// Settings that moved while we were on the network make this response answer a question
|
|
92
|
+
// nobody is asking any more.
|
|
93
|
+
guard currentRevision(scope) == revision else {
|
|
94
|
+
VoltraLogger.widget.debug("Dropping server update for '\(scope.widgetId, privacy: .public)': settings changed mid-fetch")
|
|
95
|
+
return DynamicWidgetServerUpdateResult(.dropped)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
switch result {
|
|
99
|
+
case let .notModified(nextIntervalMinutes):
|
|
100
|
+
recordSuccess(scope, epochMs(), 304)
|
|
101
|
+
return DynamicWidgetServerUpdateResult(.committed, nextIntervalMinutes: clamped(nextIntervalMinutes))
|
|
102
|
+
|
|
103
|
+
case let .networkFailure(message):
|
|
104
|
+
VoltraLogger.widget.error("Server update for '\(scope.widgetId, privacy: .public)' failed: \(message, privacy: .public)")
|
|
105
|
+
recordFailure(scope, DynamicWidgetServerStatus.errorNetwork, nil)
|
|
106
|
+
return DynamicWidgetServerUpdateResult(.retry)
|
|
107
|
+
|
|
108
|
+
case let .tooLarge(httpStatus):
|
|
109
|
+
// The server answered, with a body the extension will not hold. Asking again returns the
|
|
110
|
+
// same one, so this is a parse failure rather than something to back off from.
|
|
111
|
+
VoltraLogger.widget.error("Server update for '\(scope.widgetId, privacy: .public)' returned a body that is too large")
|
|
112
|
+
recordFailure(scope, DynamicWidgetServerStatus.errorParse, httpStatus)
|
|
113
|
+
return DynamicWidgetServerUpdateResult(.failed)
|
|
114
|
+
|
|
115
|
+
case let .httpFailure(httpStatus, retryAfterMinutes):
|
|
116
|
+
let error = result.isUnauthorized ? DynamicWidgetServerStatus.errorUnauthorized : DynamicWidgetServerStatus.errorHttp
|
|
117
|
+
|
|
118
|
+
VoltraLogger.widget.error("Server update for '\(scope.widgetId, privacy: .public)' got HTTP \(httpStatus, privacy: .public)")
|
|
119
|
+
recordFailure(scope, error, httpStatus)
|
|
120
|
+
|
|
121
|
+
// A 401 will keep being a 401 until the app sets a fresh token, and setting one reloads the
|
|
122
|
+
// widget. Retrying sooner would only spend the reload budget.
|
|
123
|
+
guard result.isTransient else {
|
|
124
|
+
return DynamicWidgetServerUpdateResult(.failed)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return DynamicWidgetServerUpdateResult(.retry, nextIntervalMinutes: clamped(retryAfterMinutes))
|
|
128
|
+
|
|
129
|
+
case let .success(body, etag, httpStatus, nextIntervalMinutes):
|
|
130
|
+
return commit(
|
|
131
|
+
scope: scope,
|
|
132
|
+
url: url,
|
|
133
|
+
body: body,
|
|
134
|
+
etag: etag,
|
|
135
|
+
httpStatus: httpStatus,
|
|
136
|
+
nextIntervalMinutes: clamped(nextIntervalMinutes)
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// What the server asked for, held to what WidgetKit can honour: never sooner than 15 minutes,
|
|
142
|
+
/// never further out than a day.
|
|
143
|
+
private func clamped(_ minutes: Int?) -> Int? {
|
|
144
|
+
minutes.map { WidgetServerUpdateDefaults.clampIntervalMinutes($0) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private func commit(
|
|
148
|
+
scope: WidgetScope,
|
|
149
|
+
url: String,
|
|
150
|
+
body: Data,
|
|
151
|
+
etag: String?,
|
|
152
|
+
httpStatus: Int,
|
|
153
|
+
nextIntervalMinutes: Int?
|
|
154
|
+
) -> DynamicWidgetServerUpdateResult {
|
|
155
|
+
switch DynamicWidgetServerProps.parse(body) {
|
|
156
|
+
case let .invalid(reason):
|
|
157
|
+
VoltraLogger.widget.error("Server update for '\(scope.widgetId, privacy: .public)' rejected: \(reason, privacy: .public)")
|
|
158
|
+
recordFailure(scope, DynamicWidgetServerStatus.errorParse, httpStatus)
|
|
159
|
+
// Asking again returns the same body, so this is not something to retry.
|
|
160
|
+
return DynamicWidgetServerUpdateResult(.failed)
|
|
161
|
+
|
|
162
|
+
case let .props(json):
|
|
163
|
+
guard trialRender(scope, json) else {
|
|
164
|
+
VoltraLogger.widget.error(
|
|
165
|
+
"Server update for '\(scope.widgetId, privacy: .public)' did not render; keeping the previous props"
|
|
166
|
+
)
|
|
167
|
+
recordFailure(scope, DynamicWidgetServerStatus.errorRender, httpStatus)
|
|
168
|
+
return DynamicWidgetServerUpdateResult(.failed)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
do {
|
|
172
|
+
try commitProps(scope, json)
|
|
173
|
+
} catch {
|
|
174
|
+
// Storage is not something the server can fix, and there is no error kind for it, so it is
|
|
175
|
+
// reported as a render failure: the props arrived and could not be put on screen.
|
|
176
|
+
VoltraLogger.widget.error(
|
|
177
|
+
"Could not store fetched props for '\(scope.widgetId, privacy: .public)': \(error.localizedDescription, privacy: .public)"
|
|
178
|
+
)
|
|
179
|
+
recordFailure(scope, DynamicWidgetServerStatus.errorRender, httpStatus)
|
|
180
|
+
return DynamicWidgetServerUpdateResult(.failed)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
writeEtag(scope, url, etag)
|
|
184
|
+
recordSuccess(scope, epochMs(), httpStatus)
|
|
185
|
+
|
|
186
|
+
return DynamicWidgetServerUpdateResult(.committed, nextIntervalMinutes: nextIntervalMinutes)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private func epochMs() -> Int {
|
|
191
|
+
Int(now().timeIntervalSince1970 * 1000)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
protocol DynamicWidgetPropsPersistence {
|
|
2
|
+
func persistDynamicWidgetProps(_ dynamicWidgetPropsJSON: String, for dynamicWidgetID: String) throws
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
extension DynamicWidgetPropsStore: DynamicWidgetPropsPersistence {}
|
|
6
|
+
|
|
7
|
+
/// Persists runtime props before requesting a targeted Dynamic Widget timeline reload.
|
|
8
|
+
struct DynamicWidgetUpdater {
|
|
9
|
+
private let dynamicWidgetPropsPersistence: any DynamicWidgetPropsPersistence
|
|
10
|
+
private let dynamicWidgetTimelineReload: (String) async throws -> Void
|
|
11
|
+
|
|
12
|
+
init(
|
|
13
|
+
dynamicWidgetPropsPersistence: any DynamicWidgetPropsPersistence,
|
|
14
|
+
dynamicWidgetTimelineReload: @escaping (String) async throws -> Void
|
|
15
|
+
) {
|
|
16
|
+
self.dynamicWidgetPropsPersistence = dynamicWidgetPropsPersistence
|
|
17
|
+
self.dynamicWidgetTimelineReload = dynamicWidgetTimelineReload
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func updateDynamicWidget(
|
|
21
|
+
dynamicWidgetID: String,
|
|
22
|
+
dynamicWidgetPropsJSON: String
|
|
23
|
+
) async throws {
|
|
24
|
+
try dynamicWidgetPropsPersistence.persistDynamicWidgetProps(
|
|
25
|
+
dynamicWidgetPropsJSON,
|
|
26
|
+
for: dynamicWidgetID
|
|
27
|
+
)
|
|
28
|
+
try await dynamicWidgetTimelineReload(dynamicWidgetID)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Decides which server response a server-driven widget renders for one timeline request.
|
|
4
|
+
///
|
|
5
|
+
/// WidgetKit requests a timeline once per widget instance per reload, and an interactive refresh
|
|
6
|
+
/// button produces two reloads for a single tap (the intent's own reload plus the automatic
|
|
7
|
+
/// post-intent reload). The resolver collapses such bursts into one network request and answers
|
|
8
|
+
/// every request in the burst with the same response. When a fetch fails it hands back the last
|
|
9
|
+
/// successful response instead, so the widget never regresses to older or initial content while
|
|
10
|
+
/// the server has already provided something newer.
|
|
11
|
+
struct ServerWidgetContentResolver {
|
|
12
|
+
enum Outcome {
|
|
13
|
+
/// Fetched now, or fetched moments ago by another request in the same burst.
|
|
14
|
+
case current(Data)
|
|
15
|
+
/// The fetch failed; this is the most recent successful response.
|
|
16
|
+
case lastKnown(Data, Error)
|
|
17
|
+
/// The fetch failed and no successful response exists in this process.
|
|
18
|
+
case unavailable(Error)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
typealias Fetch = (_ widgetId: String) async throws -> Data
|
|
22
|
+
|
|
23
|
+
static let defaultCoalesceInterval: TimeInterval = 3
|
|
24
|
+
|
|
25
|
+
private let responseStore: ServerWidgetResponseStore
|
|
26
|
+
private let fetch: Fetch
|
|
27
|
+
private let coalesceInterval: TimeInterval
|
|
28
|
+
private let now: () -> Date
|
|
29
|
+
|
|
30
|
+
init(
|
|
31
|
+
responseStore: ServerWidgetResponseStore,
|
|
32
|
+
fetch: @escaping Fetch,
|
|
33
|
+
coalesceInterval: TimeInterval = ServerWidgetContentResolver.defaultCoalesceInterval,
|
|
34
|
+
now: @escaping () -> Date = Date.init
|
|
35
|
+
) {
|
|
36
|
+
self.responseStore = responseStore
|
|
37
|
+
self.fetch = fetch
|
|
38
|
+
self.coalesceInterval = coalesceInterval
|
|
39
|
+
self.now = now
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func resolve(widgetId: String) async -> Outcome {
|
|
43
|
+
if let recent = await responseStore.response(for: widgetId),
|
|
44
|
+
now().timeIntervalSince(recent.fetchedAt) < coalesceInterval
|
|
45
|
+
{
|
|
46
|
+
return .current(recent.data)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
do {
|
|
50
|
+
let data = try await fetch(widgetId)
|
|
51
|
+
await responseStore.store(data, for: widgetId, fetchedAt: now())
|
|
52
|
+
return .current(data)
|
|
53
|
+
} catch {
|
|
54
|
+
if let lastKnown = await responseStore.response(for: widgetId) {
|
|
55
|
+
return .lastKnown(lastKnown.data, error)
|
|
56
|
+
}
|
|
57
|
+
return .unavailable(error)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|