@use-voltra/ios-client 2.2.0 → 2.3.1
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/Voltra.podspec +1 -0
- package/build/commonjs/events.js +23 -0
- package/build/commonjs/events.js.map +1 -1
- package/build/commonjs/index.js +59 -0
- 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/events.js +23 -0
- package/build/module/events.js.map +1 -1
- package/build/module/index.js +3 -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/events.d.ts +7 -0
- package/build/typescript/commonjs/events.d.ts.map +1 -1
- package/build/typescript/commonjs/index.d.ts +3 -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 +20 -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/events.d.ts +7 -0
- package/build/typescript/module/events.d.ts.map +1 -1
- package/build/typescript/module/index.d.ts +3 -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 +20 -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 +6 -0
- 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/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 +51 -11
- package/expo-plugin/build/cjs/ios-widget/xcode/buildPhases.js.map +1 -1
- package/expo-plugin/build/cjs/ios-widget/xcode/index.js +21 -5
- package/expo-plugin/build/cjs/ios-widget/xcode/index.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 +6 -0
- 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/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 +51 -11
- package/expo-plugin/build/esm/ios-widget/xcode/buildPhases.js.map +1 -1
- package/expo-plugin/build/esm/ios-widget/xcode/index.js +21 -5
- package/expo-plugin/build/esm/ios-widget/xcode/index.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/widgetPlist.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts +3 -1
- package/expo-plugin/build/types/ios-widget/xcode/buildPhases.d.ts.map +1 -1
- package/expo-plugin/build/types/ios-widget/xcode/index.d.ts +2 -1
- package/expo-plugin/build/types/ios-widget/xcode/index.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 +9 -0
- 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/widgetPlist.ts +37 -23
- package/expo-plugin/src/ios-widget/xcode/__snapshots__/applyXcodeChanges.node.test.ts.snap +34 -28
- package/expo-plugin/src/ios-widget/xcode/buildPhases.ts +59 -12
- package/expo-plugin/src/ios-widget/xcode/index.ts +23 -6
- 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 +32 -3
- package/ios/VoltraWidget.podspec +1 -0
- package/ios/app/NativeVoltra.mm +107 -7
- package/ios/app/VoltraLiveActivityManager.swift +30 -0
- package/ios/app/VoltraLiveActivityService.swift +77 -5
- package/ios/app/VoltraModule.swift +99 -1
- package/ios/app/VoltraModuleImpl.swift +100 -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/DynamicWidgetPropsStore.swift +73 -6
- 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/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 +15 -0
- package/ios/shared/VoltraElement.swift +127 -43
- package/ios/shared/VoltraEventBus.swift +52 -7
- package/ios/shared/VoltraJSRenderer.swift +90 -22
- package/ios/shared/VoltraNode.swift +1 -109
- package/ios/shared/VoltraRefreshIntent.swift +1 -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 +44 -6
- package/ios/target/VoltraDynamicWidgetServerUpdateProvider.swift +204 -0
- package/ios/target/VoltraHomeWidget.swift +85 -90
- package/ios/ui/Extensions/VoltraNode+View.swift +113 -0
- package/ios/ui/Generated/Parameters/ChartParameters.swift +3 -0
- package/ios/ui/Helpers/VoltraDeepLinkResolver.swift +2 -2
- package/ios/ui/Views/VoltraChart.swift +108 -0
- package/package.json +4 -4
- package/src/events.ts +34 -0
- package/src/index.ts +22 -0
- package/src/live-activity/api.ts +2 -2
- package/src/live-activity/dynamic-api.ts +240 -0
- package/src/native/NativeVoltra.ts +21 -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,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
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
|
|
4
|
+
/// The only way to write server-update settings, and the storage behind the three runtime layers.
|
|
5
|
+
///
|
|
6
|
+
/// Records live in the same shared Keychain the widget credentials have always used, so nothing
|
|
7
|
+
/// migrates: the deprecated `setWidgetServerCredentials` keeps writing the accounts it always did,
|
|
8
|
+
/// and this store adds its own accounts alongside them. The Keychain is also the only store both
|
|
9
|
+
/// the app and the widget extension can reach without depending on an App Group being configured.
|
|
10
|
+
///
|
|
11
|
+
/// Callers do not read through this class. They read through `WidgetServerSettingsResolver`, which
|
|
12
|
+
/// is what keeps the layer order and the merge rule in one place.
|
|
13
|
+
public enum WidgetServerSettingsStore {
|
|
14
|
+
private static let service = "voltra-widget-server-credentials"
|
|
15
|
+
private static let globalAccount = "server_update_global"
|
|
16
|
+
private static let widgetAccountPrefix = "server_update_widget_"
|
|
17
|
+
private static let revisionAccount = "server_update_revision"
|
|
18
|
+
|
|
19
|
+
// MARK: - Writes
|
|
20
|
+
|
|
21
|
+
/// Replaces the global layer, or one widget's layer when `scope` is given.
|
|
22
|
+
@discardableResult
|
|
23
|
+
public static func set(_ settings: WidgetServerUpdateSettings, scope: WidgetScope?) -> Bool {
|
|
24
|
+
guard let data = WidgetServerSettingsCodec.encode(settings) else { return false }
|
|
25
|
+
|
|
26
|
+
let saved = write(data, account: account(for: scope))
|
|
27
|
+
bumpRevision()
|
|
28
|
+
|
|
29
|
+
return saved
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Empties the global layer, or one widget's layer when `scope` is given.
|
|
33
|
+
public static func clear(scope: WidgetScope?) {
|
|
34
|
+
delete(account: account(for: scope))
|
|
35
|
+
bumpRevision()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Bumps the revision without changing a layer. The credentials layer writes through the
|
|
39
|
+
/// deprecated credential API, which does not go through `set`, so it calls this to make sure an
|
|
40
|
+
/// in-flight fetch built with the old token does not commit.
|
|
41
|
+
public static func bumpRevision() {
|
|
42
|
+
let next = revision() &+ 1
|
|
43
|
+
write(Data("\(next)".utf8), account: revisionAccount)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public static func revision() -> Int {
|
|
47
|
+
guard let data = read(account: revisionAccount),
|
|
48
|
+
let text = String(data: data, encoding: .utf8),
|
|
49
|
+
let value = Int(text)
|
|
50
|
+
else {
|
|
51
|
+
return 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// MARK: - Reads
|
|
58
|
+
|
|
59
|
+
static func settings(scope: WidgetScope?) -> WidgetServerUpdateSettings? {
|
|
60
|
+
WidgetServerSettingsCodec.decode(read(account: account(for: scope)))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private static func account(for scope: WidgetScope?) -> String {
|
|
64
|
+
guard let scope else { return globalAccount }
|
|
65
|
+
|
|
66
|
+
return "\(widgetAccountPrefix)\(scope.storageKey)"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// MARK: - Keychain
|
|
70
|
+
|
|
71
|
+
private static func baseQuery(account: String) -> [String: Any] {
|
|
72
|
+
var query: [String: Any] = [
|
|
73
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
74
|
+
kSecAttrService as String: service,
|
|
75
|
+
kSecAttrAccount as String: account,
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
if let group = VoltraConfig.keychainGroup() {
|
|
79
|
+
query[kSecAttrAccessGroup as String] = group
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return query
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@discardableResult
|
|
86
|
+
private static func write(_ data: Data, account: String) -> Bool {
|
|
87
|
+
delete(account: account)
|
|
88
|
+
|
|
89
|
+
var query = baseQuery(account: account)
|
|
90
|
+
query[kSecValueData as String] = data
|
|
91
|
+
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
92
|
+
|
|
93
|
+
let status = SecItemAdd(query as CFDictionary, nil)
|
|
94
|
+
|
|
95
|
+
if status != errSecSuccess {
|
|
96
|
+
VoltraLogger.keychain.error("Failed to save widget server settings: \(status, privacy: .public)")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return status == errSecSuccess
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private static func read(account: String) -> Data? {
|
|
103
|
+
var query = baseQuery(account: account)
|
|
104
|
+
query[kSecReturnData as String] = true
|
|
105
|
+
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
106
|
+
|
|
107
|
+
var item: CFTypeRef?
|
|
108
|
+
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
109
|
+
|
|
110
|
+
guard status == errSecSuccess, let data = item as? Data else {
|
|
111
|
+
if status != errSecItemNotFound {
|
|
112
|
+
VoltraLogger.keychain.error("Failed to read widget server settings: \(status, privacy: .public)")
|
|
113
|
+
}
|
|
114
|
+
return nil
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return data
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private static func delete(account: String) {
|
|
121
|
+
SecItemDelete(baseQuery(account: account) as CFDictionary)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Settings the app set for every server-driven widget.
|
|
126
|
+
struct GlobalWidgetServerSettingsLayer: WidgetServerSettingsLayer {
|
|
127
|
+
let name = "global"
|
|
128
|
+
|
|
129
|
+
func settings(for _: WidgetScope) -> WidgetServerUpdateSettings? {
|
|
130
|
+
WidgetServerSettingsStore.settings(scope: nil)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// Raw contents of this layer, for reading back what was set — no scope to resolve against.
|
|
134
|
+
func raw() -> WidgetServerUpdateSettings? {
|
|
135
|
+
WidgetServerSettingsStore.settings(scope: nil)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Settings the app set for one widget. Highest layer until instance scopes arrive.
|
|
140
|
+
struct WidgetWidgetServerSettingsLayer: WidgetServerSettingsLayer {
|
|
141
|
+
let name = "widget"
|
|
142
|
+
|
|
143
|
+
func settings(for scope: WidgetScope) -> WidgetServerUpdateSettings? {
|
|
144
|
+
WidgetServerSettingsStore.settings(scope: scope)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// The deprecated `setWidgetServerCredentials` API, expressed as a settings layer.
|
|
149
|
+
///
|
|
150
|
+
/// It reads the same token and header records it always has, which is why nothing migrates. It
|
|
151
|
+
/// sits below the global layer so an app that has moved to
|
|
152
|
+
/// `setWidgetServerUpdate({ headers: { Authorization: ... } })` overrides whatever an older call
|
|
153
|
+
/// left behind, rather than the other way round.
|
|
154
|
+
struct CredentialsWidgetServerSettingsLayer: WidgetServerSettingsLayer {
|
|
155
|
+
let name = "credentials"
|
|
156
|
+
|
|
157
|
+
func settings(for _: WidgetScope) -> WidgetServerUpdateSettings? {
|
|
158
|
+
var headers: [String: String] = [:]
|
|
159
|
+
|
|
160
|
+
if let token = VoltraKeychainHelper.readToken(), !token.isEmpty {
|
|
161
|
+
headers["Authorization"] = "Bearer \(token)"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if let custom = VoltraKeychainHelper.readHeaders() {
|
|
165
|
+
headers.merge(custom) { _, fromCustom in fromCustom }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return headers.isEmpty ? nil : WidgetServerUpdateSettings(headers: headers)
|
|
169
|
+
}
|
|
170
|
+
}
|