@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,55 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The unit everything server-driven is keyed by: settings, fetched props, the stored ETag, fetch
|
|
4
|
+
/// coalescing, and the settings revision.
|
|
5
|
+
///
|
|
6
|
+
/// `.widget` is a whole widget id. `.instance` (ADR 0007) is one distinct merged configuration of a
|
|
7
|
+
/// widget: two placements with identical configuration share one key, one fetch, one ETag and one
|
|
8
|
+
/// props slot. The settings resolver, ETag store, status store, runner and request builder take
|
|
9
|
+
/// either case with no signature change; the settings layers keep resolving by widget id, so an
|
|
10
|
+
/// instance inherits the widget's URL, interval, method, headers and body.
|
|
11
|
+
public enum WidgetScope: Hashable, Sendable {
|
|
12
|
+
case widget(id: String)
|
|
13
|
+
|
|
14
|
+
/// One distinct merged configuration of `id`. `key` is the hash of that configuration's
|
|
15
|
+
/// canonical serialization (see `WidgetCanonicalConfiguration`) — opaque, stable, and produced
|
|
16
|
+
/// identically on Android for the same configuration. WidgetKit has no placement id, so this is
|
|
17
|
+
/// the only identity an iOS placement can have.
|
|
18
|
+
case instance(id: String, key: String)
|
|
19
|
+
|
|
20
|
+
/// Widget id this scope belongs to. An instance scope reports the id it is an instance of.
|
|
21
|
+
public var widgetId: String {
|
|
22
|
+
switch self {
|
|
23
|
+
case let .widget(id):
|
|
24
|
+
return id
|
|
25
|
+
case let .instance(id, _):
|
|
26
|
+
return id
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Stable key for per-scope storage. An instance scope appends its placement key, so
|
|
31
|
+
/// widget-scoped records written today keep their keys.
|
|
32
|
+
public var storageKey: String {
|
|
33
|
+
switch self {
|
|
34
|
+
case let .widget(id):
|
|
35
|
+
return id
|
|
36
|
+
case let .instance(id, key):
|
|
37
|
+
return "\(id)#\(key)"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Convenience for the common case, so callers do not spell out the case name.
|
|
42
|
+
public static func of(_ widgetId: String) -> WidgetScope {
|
|
43
|
+
.widget(id: widgetId)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// The scope for one placement's merged `configuration` (ADR 0007): `.instance` when the
|
|
47
|
+
/// configuration is non-empty, `.widget` when it is — a widget with no configuration parameters
|
|
48
|
+
/// has exactly one instance, the widget scope, and its requests and storage are unchanged.
|
|
49
|
+
public static func of(_ widgetId: String, configuration: [String: String]) -> WidgetScope {
|
|
50
|
+
if let key = WidgetCanonicalConfiguration.key(configuration) {
|
|
51
|
+
return .instance(id: widgetId, key: key)
|
|
52
|
+
}
|
|
53
|
+
return .widget(id: widgetId)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The ETag from the last `200`, stored with the URL it came from.
|
|
4
|
+
///
|
|
5
|
+
/// Keeping the URL alongside it is what makes `If-None-Match` safe once the app can change the URL
|
|
6
|
+
/// at runtime: an ETag minted by one endpoint says nothing about another, and sending it could
|
|
7
|
+
/// produce a `304` that leaves the widget showing the previous endpoint's data forever.
|
|
8
|
+
///
|
|
9
|
+
/// Lives in the App Group so the widget extension and the app agree on it. Without an App Group
|
|
10
|
+
/// there is nowhere shared to put it, and the widget simply revalidates nothing.
|
|
11
|
+
public enum WidgetServerEtagStore {
|
|
12
|
+
/// The stored ETag, but only if it was minted by `url`.
|
|
13
|
+
public static func etag(for scope: WidgetScope, url: String?) -> String? {
|
|
14
|
+
guard let url, let defaults = defaults(),
|
|
15
|
+
defaults.string(forKey: urlKey(scope)) == url
|
|
16
|
+
else {
|
|
17
|
+
return nil
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return defaults.string(forKey: etagKey(scope))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public static func put(_ etag: String?, for scope: WidgetScope, url: String) {
|
|
24
|
+
guard let defaults = defaults() else { return }
|
|
25
|
+
|
|
26
|
+
guard let etag else {
|
|
27
|
+
clear(scope)
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
defaults.set(etag, forKey: etagKey(scope))
|
|
32
|
+
defaults.set(url, forKey: urlKey(scope))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public static func clear(_ scope: WidgetScope) {
|
|
36
|
+
guard let defaults = defaults() else { return }
|
|
37
|
+
|
|
38
|
+
defaults.removeObject(forKey: etagKey(scope))
|
|
39
|
+
defaults.removeObject(forKey: urlKey(scope))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private static func defaults() -> UserDefaults? {
|
|
43
|
+
guard let group = VoltraConfig.groupIdentifier() else { return nil }
|
|
44
|
+
|
|
45
|
+
return UserDefaults(suiteName: group)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private static func etagKey(_ scope: WidgetScope) -> String {
|
|
49
|
+
"Voltra_WidgetServerEtag_\(scope.storageKey)"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private static func urlKey(_ scope: WidgetScope) -> String {
|
|
53
|
+
"Voltra_WidgetServerEtagUrl_\(scope.storageKey)"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// What came back from the server, before either engine decides what it means.
|
|
4
|
+
///
|
|
5
|
+
/// The request side is identical for a payload widget and a Dynamic Widget, so it lives here once.
|
|
6
|
+
/// Only the interpretation of `body` differs: one parses a Voltra payload, the other props.
|
|
7
|
+
public enum WidgetServerFetchResult: Sendable {
|
|
8
|
+
/// `200` with a body. `etag` is present when the response carried one.
|
|
9
|
+
case success(body: Data, etag: String?, httpStatus: Int, nextIntervalMinutes: Int?)
|
|
10
|
+
/// `304`: what is already committed is still current.
|
|
11
|
+
case notModified(nextIntervalMinutes: Int?)
|
|
12
|
+
/// The request never completed: no connectivity, DNS, TLS, or a timeout.
|
|
13
|
+
case networkFailure(message: String)
|
|
14
|
+
/// The server answered with a status we cannot use.
|
|
15
|
+
case httpFailure(httpStatus: Int, retryAfterMinutes: Int?)
|
|
16
|
+
/// A `2xx` whose body is over `WidgetServerUpdateDefaults.maxBodyBytes`. Kept apart from
|
|
17
|
+
/// `httpFailure` because the server did answer: this is a body the device refuses, so it is
|
|
18
|
+
/// reported as a parse failure and asking again is pointless.
|
|
19
|
+
case tooLarge(httpStatus: Int)
|
|
20
|
+
|
|
21
|
+
public var isUnauthorized: Bool {
|
|
22
|
+
if case let .httpFailure(status, _) = self {
|
|
23
|
+
return status == 401 || status == 403
|
|
24
|
+
}
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// True when waiting and asking again could plausibly succeed.
|
|
29
|
+
public var isTransient: Bool {
|
|
30
|
+
switch self {
|
|
31
|
+
case .networkFailure:
|
|
32
|
+
return true
|
|
33
|
+
case let .httpFailure(status, _):
|
|
34
|
+
return status >= 500 || status == 429
|
|
35
|
+
default:
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Executes a request built by `WidgetServerRequestBuilder` and reports what happened, without
|
|
42
|
+
/// deciding what to do about it.
|
|
43
|
+
public enum WidgetServerFetcher {
|
|
44
|
+
/// Refuses to leave the host the app configured, so an `Authorization` header or a request body
|
|
45
|
+
/// cannot be replayed somewhere the app never agreed to send it.
|
|
46
|
+
private final class SameHostRedirectDelegate: NSObject, URLSessionTaskDelegate {
|
|
47
|
+
func urlSession(
|
|
48
|
+
_: URLSession,
|
|
49
|
+
task: URLSessionTask,
|
|
50
|
+
willPerformHTTPRedirection _: HTTPURLResponse,
|
|
51
|
+
newRequest request: URLRequest,
|
|
52
|
+
completionHandler: @escaping (URLRequest?) -> Void
|
|
53
|
+
) {
|
|
54
|
+
guard let originalHost = task.originalRequest?.url?.host,
|
|
55
|
+
let originalScheme = task.originalRequest?.url?.scheme,
|
|
56
|
+
let nextHost = request.url?.host,
|
|
57
|
+
nextHost.caseInsensitiveCompare(originalHost) == .orderedSame,
|
|
58
|
+
request.url?.scheme == originalScheme
|
|
59
|
+
else {
|
|
60
|
+
VoltraLogger.widget.warning("Refusing cross-host redirect for a widget server request")
|
|
61
|
+
completionHandler(nil)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
completionHandler(request)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private static let redirectDelegate = SameHostRedirectDelegate()
|
|
70
|
+
|
|
71
|
+
public static func fetch(
|
|
72
|
+
_ request: URLRequest,
|
|
73
|
+
session: URLSession = .shared
|
|
74
|
+
) async -> WidgetServerFetchResult {
|
|
75
|
+
do {
|
|
76
|
+
// bytes(for:) rather than data(for:) so an oversized body is abandoned mid-stream. A widget
|
|
77
|
+
// extension has a 30 MB ceiling for the whole render, and buffering first would spend it
|
|
78
|
+
// before we ever got to check.
|
|
79
|
+
let (stream, response) = try await session.bytes(for: request, delegate: redirectDelegate)
|
|
80
|
+
|
|
81
|
+
guard let http = response as? HTTPURLResponse else {
|
|
82
|
+
return .networkFailure(message: "Response was not an HTTP response")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let nextIntervalMinutes = maxAgeMinutes(http.value(forHTTPHeaderField: "Cache-Control"))
|
|
86
|
+
|
|
87
|
+
if http.statusCode == 304 {
|
|
88
|
+
return .notModified(nextIntervalMinutes: nextIntervalMinutes)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
guard (200 ... 299).contains(http.statusCode) else {
|
|
92
|
+
return .httpFailure(
|
|
93
|
+
httpStatus: http.statusCode,
|
|
94
|
+
retryAfterMinutes: retryAfterMinutes(http.value(forHTTPHeaderField: "Retry-After"))
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
guard let body = try await readBody(stream) else {
|
|
99
|
+
// Asking again returns the same oversized body, so this is a failure the app has to fix
|
|
100
|
+
// rather than one to back off from.
|
|
101
|
+
VoltraLogger.widget.error(
|
|
102
|
+
"Response is larger than \(WidgetServerUpdateDefaults.maxBodyBytes, privacy: .public) bytes"
|
|
103
|
+
)
|
|
104
|
+
return .tooLarge(httpStatus: http.statusCode)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return .success(
|
|
108
|
+
body: body,
|
|
109
|
+
etag: http.value(forHTTPHeaderField: "ETag"),
|
|
110
|
+
httpStatus: http.statusCode,
|
|
111
|
+
nextIntervalMinutes: nextIntervalMinutes
|
|
112
|
+
)
|
|
113
|
+
} catch {
|
|
114
|
+
return .networkFailure(message: error.localizedDescription)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// Returns nil as soon as the body passes the cap, without holding the rest of it.
|
|
119
|
+
private static func readBody(_ stream: URLSession.AsyncBytes) async throws -> Data? {
|
|
120
|
+
var body = Data()
|
|
121
|
+
body.reserveCapacity(16 * 1024)
|
|
122
|
+
|
|
123
|
+
for try await byte in stream {
|
|
124
|
+
if body.count >= WidgetServerUpdateDefaults.maxBodyBytes {
|
|
125
|
+
return nil
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
body.append(byte)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return body
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// `Cache-Control: max-age=N`, in minutes, rounded down.
|
|
135
|
+
static func maxAgeMinutes(_ header: String?) -> Int? {
|
|
136
|
+
guard let header else { return nil }
|
|
137
|
+
|
|
138
|
+
let pattern = try? NSRegularExpression(pattern: "max-age\\s*=\\s*(\\d+)", options: .caseInsensitive)
|
|
139
|
+
let range = NSRange(header.startIndex ..< header.endIndex, in: header)
|
|
140
|
+
|
|
141
|
+
guard let match = pattern?.firstMatch(in: header, range: range),
|
|
142
|
+
let secondsRange = Range(match.range(at: 1), in: header),
|
|
143
|
+
let seconds = Int(header[secondsRange])
|
|
144
|
+
else {
|
|
145
|
+
return nil
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return seconds / 60
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// `Retry-After`, in minutes, rounded up so we never retry early.
|
|
152
|
+
///
|
|
153
|
+
/// The header is delta-seconds or an HTTP date; both are in the wild, so both are read.
|
|
154
|
+
static func retryAfterMinutes(_ header: String?, now: Date = Date()) -> Int? {
|
|
155
|
+
guard let value = header?.trimmingCharacters(in: .whitespaces), !value.isEmpty else {
|
|
156
|
+
return nil
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if let seconds = Int(value) {
|
|
160
|
+
return seconds > 0 ? (seconds + 59) / 60 : nil
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
guard let date = httpDateFormatter.date(from: value) else {
|
|
164
|
+
return nil
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let seconds = Int(date.timeIntervalSince(now))
|
|
168
|
+
|
|
169
|
+
return seconds > 0 ? (seconds + 59) / 60 : nil
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private static let httpDateFormatter: DateFormatter = {
|
|
173
|
+
let formatter = DateFormatter()
|
|
174
|
+
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
175
|
+
formatter.timeZone = TimeZone(identifier: "GMT")
|
|
176
|
+
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
|
177
|
+
return formatter
|
|
178
|
+
}()
|
|
179
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The device state a request carries that is not part of the settings: the theme and locale the
|
|
4
|
+
/// widget is being drawn in, and who is asking.
|
|
5
|
+
///
|
|
6
|
+
/// Passed in rather than read here so the request contract can be tested without UIKit, and so a
|
|
7
|
+
/// timeline request built for one appearance cannot accidentally report another.
|
|
8
|
+
public struct WidgetServerRequestContext: Equatable, Sendable {
|
|
9
|
+
public let theme: String
|
|
10
|
+
public let locale: String
|
|
11
|
+
public let userAgent: String
|
|
12
|
+
/// Only payload widgets send `family`: one Dynamic Widget fetch serves every size, so its props
|
|
13
|
+
/// must be size-agnostic and the entry picks its layout from `env.widgetFamily`.
|
|
14
|
+
public let family: String?
|
|
15
|
+
|
|
16
|
+
public init(theme: String, locale: String, userAgent: String, family: String? = nil) {
|
|
17
|
+
self.theme = theme
|
|
18
|
+
self.locale = locale
|
|
19
|
+
self.userAgent = userAgent
|
|
20
|
+
self.family = family
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Turns resolved settings plus Voltra's own request parameters into the request the device sends.
|
|
25
|
+
///
|
|
26
|
+
/// Both engines build their requests here, so a payload widget and a Dynamic Widget send the same
|
|
27
|
+
/// shape and the app's runtime overrides apply to both. Only the response and what the device does
|
|
28
|
+
/// with it differ.
|
|
29
|
+
public enum WidgetServerRequestBuilder {
|
|
30
|
+
/// Widgets have limited execution time, so a request that has not answered in this long is not
|
|
31
|
+
/// going to be useful even if it eventually does.
|
|
32
|
+
public static let timeoutSeconds: TimeInterval = 15
|
|
33
|
+
|
|
34
|
+
/// - Parameter etag: from the last `200`, sent as `If-None-Match`. Callers pass nil when the
|
|
35
|
+
/// stored ETag belongs to a different URL than the one being fetched now.
|
|
36
|
+
/// - Returns: nil when there is nothing to fetch — no URL, fetching is off, or the URL will not
|
|
37
|
+
/// parse.
|
|
38
|
+
public static func build(
|
|
39
|
+
scope: WidgetScope,
|
|
40
|
+
settings: ResolvedWidgetServerSettings,
|
|
41
|
+
context: WidgetServerRequestContext,
|
|
42
|
+
etag: String? = nil,
|
|
43
|
+
// The placement's merged configuration (ADR 0007). Empty for a widget with no configuration
|
|
44
|
+
// parameters, or when the caller only knows the scope's key — the caller is responsible for
|
|
45
|
+
// passing the same map that produced `scope`'s key, since the key alone cannot be reversed
|
|
46
|
+
// back into the configuration that produced it.
|
|
47
|
+
configuration: [String: String] = [:]
|
|
48
|
+
) -> URLRequest? {
|
|
49
|
+
guard settings.shouldFetch, let url = settings.url, var components = URLComponents(string: url) else {
|
|
50
|
+
return nil
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
var queryItems = components.queryItems ?? []
|
|
54
|
+
queryItems.append(URLQueryItem(name: "widgetId", value: scope.widgetId))
|
|
55
|
+
queryItems.append(URLQueryItem(name: "platform", value: "ios"))
|
|
56
|
+
|
|
57
|
+
if let family = context.family {
|
|
58
|
+
queryItems.append(URLQueryItem(name: "family", value: family))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
queryItems.append(URLQueryItem(name: "theme", value: context.theme))
|
|
62
|
+
queryItems.append(URLQueryItem(name: "locale", value: context.locale))
|
|
63
|
+
|
|
64
|
+
// Present for every method, absent when the widget has no configuration parameters (ADR
|
|
65
|
+
// 0007). `instance` is the hash of the canonical `configuration`, sent so a backend can cache
|
|
66
|
+
// or log per instance without recomputing it.
|
|
67
|
+
if case let .instance(_, key) = scope, let canonicalConfiguration = WidgetCanonicalConfiguration.canonicalize(configuration) {
|
|
68
|
+
queryItems.append(URLQueryItem(name: "instance", value: key))
|
|
69
|
+
queryItems.append(URLQueryItem(name: "configuration", value: canonicalConfiguration))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Voltra's own keys are appended first and the app's keys are rejected at call time if they
|
|
73
|
+
// collide, so nothing here can shadow what the server relies on.
|
|
74
|
+
for key in settings.query.keys.sorted() {
|
|
75
|
+
queryItems.append(URLQueryItem(name: key, value: settings.query[key]))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
components.queryItems = queryItems
|
|
79
|
+
|
|
80
|
+
guard let resolvedUrl = components.url else { return nil }
|
|
81
|
+
|
|
82
|
+
var request = URLRequest(url: resolvedUrl)
|
|
83
|
+
let method = settings.method.uppercased()
|
|
84
|
+
|
|
85
|
+
request.httpMethod = method
|
|
86
|
+
request.timeoutInterval = timeoutSeconds
|
|
87
|
+
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
88
|
+
request.setValue(context.userAgent, forHTTPHeaderField: "User-Agent")
|
|
89
|
+
|
|
90
|
+
for (key, value) in settings.headers {
|
|
91
|
+
request.setValue(value, forHTTPHeaderField: key)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if let etag {
|
|
95
|
+
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if let body = settings.body {
|
|
99
|
+
if WidgetServerUpdateDefaults.bodylessMethods.contains(method) {
|
|
100
|
+
// URLSession sends a Content-Length and drops the body on GET, so the server would see a
|
|
101
|
+
// request the app did not mean to send. Dropping it here is the lesser surprise, and it is
|
|
102
|
+
// documented.
|
|
103
|
+
VoltraLogger.widget.warning(
|
|
104
|
+
"Dropping request body for widget '\(scope.widgetId, privacy: .public)': \(method, privacy: .public) cannot carry one"
|
|
105
|
+
)
|
|
106
|
+
} else {
|
|
107
|
+
request.httpBody = Data(body.utf8)
|
|
108
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return request
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Serializes one settings layer for storage. Written as a small versioned envelope so a future
|
|
4
|
+
/// shape change can be recognised rather than guessed at, the same way Dynamic Widget props are
|
|
5
|
+
/// stored.
|
|
6
|
+
public enum WidgetServerSettingsCodec {
|
|
7
|
+
private static let versionKey = "widgetServerSettingsVersion"
|
|
8
|
+
private static let settingsKey = "widgetServerSettings"
|
|
9
|
+
private static let version = 1
|
|
10
|
+
|
|
11
|
+
public static func encode(_ settings: WidgetServerUpdateSettings) -> Data? {
|
|
12
|
+
var payload: [String: Any] = [:]
|
|
13
|
+
|
|
14
|
+
settings.url.map { payload["url"] = $0 }
|
|
15
|
+
settings.intervalMinutes.map { payload["intervalMinutes"] = $0 }
|
|
16
|
+
settings.enabled.map { payload["enabled"] = $0 }
|
|
17
|
+
settings.method.map { payload["method"] = $0 }
|
|
18
|
+
settings.query.map { payload["query"] = $0 }
|
|
19
|
+
settings.headers.map { payload["headers"] = $0 }
|
|
20
|
+
settings.body.map { payload["body"] = $0 }
|
|
21
|
+
|
|
22
|
+
let envelope: [String: Any] = [versionKey: version, settingsKey: payload]
|
|
23
|
+
|
|
24
|
+
return try? JSONSerialization.data(withJSONObject: envelope)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Returns nil for anything this version cannot read, so a bad record reads as "no opinion".
|
|
28
|
+
public static func decode(_ data: Data?) -> WidgetServerUpdateSettings? {
|
|
29
|
+
guard let data,
|
|
30
|
+
let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
31
|
+
envelope[versionKey] as? Int == version,
|
|
32
|
+
let payload = envelope[settingsKey] as? [String: Any]
|
|
33
|
+
else {
|
|
34
|
+
return nil
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return WidgetServerUpdateSettings(
|
|
38
|
+
url: payload["url"] as? String,
|
|
39
|
+
intervalMinutes: payload["intervalMinutes"] as? Int,
|
|
40
|
+
enabled: payload["enabled"] as? Bool,
|
|
41
|
+
method: payload["method"] as? String,
|
|
42
|
+
query: payload["query"] as? [String: String],
|
|
43
|
+
headers: payload["headers"] as? [String: String],
|
|
44
|
+
body: payload["body"] as? String
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// One source of server-update settings. Implementations return a partial
|
|
4
|
+
/// `WidgetServerUpdateSettings`, or nil when they have nothing to say about the scope.
|
|
5
|
+
///
|
|
6
|
+
/// Layers are stacked in a fixed order by `WidgetServerSettingsResolver` and never consulted
|
|
7
|
+
/// directly: nothing outside this folder reads Info.plist, the Keychain, or anything else for
|
|
8
|
+
/// server-update purposes.
|
|
9
|
+
public protocol WidgetServerSettingsLayer: Sendable {
|
|
10
|
+
/// A short name used in logs, so a surprising resolved value can be traced to its source.
|
|
11
|
+
var name: String { get }
|
|
12
|
+
|
|
13
|
+
func settings(for scope: WidgetScope) -> WidgetServerUpdateSettings?
|
|
14
|
+
|
|
15
|
+
/// Whether this layer knows the scope to be server-driven at all. Only the config layer can
|
|
16
|
+
/// answer this — a runtime layer setting a URL does not turn a locally-rendered widget into a
|
|
17
|
+
/// server-driven one, because the engine is chosen at generate time.
|
|
18
|
+
func isServerDriven(_ scope: WidgetScope) -> Bool
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public extension WidgetServerSettingsLayer {
|
|
22
|
+
func isServerDriven(_: WidgetScope) -> Bool {
|
|
23
|
+
false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// The only way to read server-update settings.
|
|
28
|
+
///
|
|
29
|
+
/// Layers are walked lowest to highest and merged by the rule stated once here: `headers` and
|
|
30
|
+
/// `query` merge per key, everything else takes the value from the highest layer that sets it.
|
|
31
|
+
/// Adding a layer later — an instance layer above `widget`, say — is a new
|
|
32
|
+
/// `WidgetServerSettingsLayer` plus one entry in `layers`; this API and every caller stay as they
|
|
33
|
+
/// are.
|
|
34
|
+
public struct WidgetServerSettingsResolver: Sendable {
|
|
35
|
+
private let layers: [any WidgetServerSettingsLayer]
|
|
36
|
+
private let revisionSource: @Sendable () -> Int
|
|
37
|
+
|
|
38
|
+
/// - Parameter layers: lowest priority first: config, credentials, global, widget.
|
|
39
|
+
public init(layers: [any WidgetServerSettingsLayer], revisionSource: @escaping @Sendable () -> Int) {
|
|
40
|
+
self.layers = layers
|
|
41
|
+
self.revisionSource = revisionSource
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Flattens every layer for `scope`. Safe to call for any widget: a widget that is not
|
|
45
|
+
/// server-driven resolves to disabled with no URL, so a caller that fetches on `shouldFetch`
|
|
46
|
+
/// does nothing rather than guessing.
|
|
47
|
+
public func resolve(_ scope: WidgetScope) -> ResolvedWidgetServerSettings {
|
|
48
|
+
var merged = WidgetServerUpdateSettings.empty
|
|
49
|
+
var intervalFromConfig = false
|
|
50
|
+
|
|
51
|
+
for (index, layer) in layers.enumerated() {
|
|
52
|
+
guard let settings = layer.settings(for: scope) else { continue }
|
|
53
|
+
|
|
54
|
+
if settings.intervalMinutes != nil {
|
|
55
|
+
// Index 0 is the config layer. An interval that came from app.json was already validated
|
|
56
|
+
// against this platform's rules when the native project was generated, so clamping it
|
|
57
|
+
// again here would silently change an existing widget's schedule.
|
|
58
|
+
intervalFromConfig = index == 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
merged = Self.merge(lower: merged, higher: settings)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let serverDriven = isServerDriven(scope)
|
|
65
|
+
let url = serverDriven ? merged.url.flatMap { $0.isEmpty ? nil : $0 } : nil
|
|
66
|
+
let intervalMinutes = merged.intervalMinutes ?? WidgetServerUpdateDefaults.defaultIntervalMinutes
|
|
67
|
+
|
|
68
|
+
return ResolvedWidgetServerSettings(
|
|
69
|
+
url: url,
|
|
70
|
+
intervalMinutes: intervalFromConfig
|
|
71
|
+
? intervalMinutes
|
|
72
|
+
: WidgetServerUpdateDefaults.clampIntervalMinutes(intervalMinutes),
|
|
73
|
+
enabled: serverDriven && (merged.enabled ?? true),
|
|
74
|
+
method: merged.method ?? WidgetServerUpdateDefaults.defaultMethod,
|
|
75
|
+
query: merged.query ?? [:],
|
|
76
|
+
headers: merged.headers ?? [:],
|
|
77
|
+
body: merged.body
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// True when app.json marked this widget server-driven. The engine is chosen at generate time,
|
|
82
|
+
/// so a runtime URL cannot make a widget server-driven and `setWidgetServerUpdate` rejects
|
|
83
|
+
/// settings for one that is not.
|
|
84
|
+
public func isServerDriven(_ scope: WidgetScope) -> Bool {
|
|
85
|
+
layers.contains { $0.isServerDriven(scope) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Raw contents of the global layer: no defaulting, no merge — there is nothing to resolve
|
|
89
|
+
/// against without a widget scope. Nil when nothing has been set globally.
|
|
90
|
+
public func globalSettings() -> WidgetServerUpdateSettings? {
|
|
91
|
+
layers.lazy.compactMap { $0 as? GlobalWidgetServerSettingsLayer }.first?.raw()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Monotonic counter of settings changes. A fetcher records it before fetching and commits only
|
|
95
|
+
/// if it is still current, so settings changed mid-flight cannot commit a response built from
|
|
96
|
+
/// the old ones.
|
|
97
|
+
///
|
|
98
|
+
/// It is one counter for the whole store rather than one per scope: a change to another widget
|
|
99
|
+
/// can make an in-flight fetch drop its result, and the reload that every `set` queues fetches
|
|
100
|
+
/// again, so the cost is one wasted request in a rare race.
|
|
101
|
+
public func revision(_: WidgetScope) -> Int {
|
|
102
|
+
revisionSource()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static func merge(
|
|
106
|
+
lower: WidgetServerUpdateSettings,
|
|
107
|
+
higher: WidgetServerUpdateSettings
|
|
108
|
+
) -> WidgetServerUpdateSettings {
|
|
109
|
+
WidgetServerUpdateSettings(
|
|
110
|
+
url: higher.url ?? lower.url,
|
|
111
|
+
intervalMinutes: higher.intervalMinutes ?? lower.intervalMinutes,
|
|
112
|
+
enabled: higher.enabled ?? lower.enabled,
|
|
113
|
+
method: higher.method ?? lower.method,
|
|
114
|
+
query: mergePerKey(lower.query, higher.query),
|
|
115
|
+
headers: mergePerKey(lower.headers, higher.headers),
|
|
116
|
+
body: higher.body ?? lower.body
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private static func mergePerKey(
|
|
121
|
+
_ lower: [String: String]?,
|
|
122
|
+
_ higher: [String: String]?
|
|
123
|
+
) -> [String: String]? {
|
|
124
|
+
guard let lower else { return higher }
|
|
125
|
+
guard let higher else { return lower }
|
|
126
|
+
|
|
127
|
+
return lower.merging(higher) { _, fromHigher in fromHigher }
|
|
128
|
+
}
|
|
129
|
+
}
|