@bubblsdk/react-native-sdk 4.1.7 → 5.0.0-alpha.2
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/BubblReactNativeSdk.podspec +29 -9
- package/CHANGELOG.md +52 -1
- package/LICENSE +202 -1
- package/README.md +172 -83
- package/android/build.gradle +33 -30
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
- package/app.plugin.js +2 -0
- package/ios/BubblPush.swift +44 -0
- package/ios/BubblSdk.h +7 -0
- package/ios/BubblSdk.mm +252 -0
- package/ios/BubblSdkImpl.swift +335 -0
- package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
- package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
- package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
- package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
- package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
- package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
- package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
- package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
- package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
- package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
- package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-sdk/BubblCore/API/Http.swift +39 -0
- package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
- package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
- package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
- package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
- package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
- package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
- package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
- package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
- package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
- package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
- package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
- package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
- package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
- package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
- package/ios-sdk/BubblSDK/BubblPermissions.swift +79 -0
- package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
- package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
- package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +320 -0
- package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
- package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
- package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
- package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
- package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
- package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
- package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
- package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +169 -0
- package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
- package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
- package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
- package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
- package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
- package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
- package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
- package/jest/index.js +75 -0
- package/lib/commonjs/BubblModule.js +14 -0
- package/lib/commonjs/BubblModule.js.map +1 -0
- package/lib/commonjs/BubblModule.web.js +13 -0
- package/lib/commonjs/BubblModule.web.js.map +1 -0
- package/lib/commonjs/NativeBubblSdk.js +20 -0
- package/lib/commonjs/NativeBubblSdk.js.map +1 -0
- package/lib/commonjs/index.js +336 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/BubblModule.js +5 -0
- package/lib/module/BubblModule.js.map +1 -0
- package/lib/module/BubblModule.web.js +9 -0
- package/lib/module/BubblModule.web.js.map +1 -0
- package/lib/module/NativeBubblSdk.js +18 -0
- package/lib/module/NativeBubblSdk.js.map +1 -0
- package/lib/module/index.js +331 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +111 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/types.d.ts +192 -0
- package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/BubblModule.d.ts +3 -0
- package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +111 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/lib/typescript/module/src/types.d.ts +192 -0
- package/lib/typescript/module/src/types.d.ts.map +1 -0
- package/package.json +105 -24
- package/plugin/build/imageNotifications.d.ts +35 -0
- package/plugin/build/imageNotifications.js +198 -0
- package/plugin/build/index.d.ts +84 -0
- package/plugin/build/index.js +176 -0
- package/src/BubblModule.ts +2 -0
- package/src/BubblModule.web.ts +9 -0
- package/src/NativeBubblSdk.ts +67 -0
- package/src/index.ts +338 -282
- package/src/types.ts +182 -0
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
- package/ios/BubblSdk.swift +0 -802
- package/ios/BubblSdkBridge.m +0 -86
- package/react-native.config.js +0 -11
- package/src/specs/NativeBubblSdk.ts +0 -54
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Why a request wasn't made, other than having no network. Both mean "later": nothing was
|
|
4
|
+
/// refused, and the install keeps its identity.
|
|
5
|
+
package enum DeviceApiError: Error {
|
|
6
|
+
/// The credential can't be read yet (the device hasn't been unlocked since it started).
|
|
7
|
+
/// Registering now would replace a working credential, so the request waits instead.
|
|
8
|
+
case credentialsLocked
|
|
9
|
+
/// POST /installs succeeded but its credential couldn't be kept, so it doesn't count.
|
|
10
|
+
case credentialNotSaved(any Error)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// The engine's client for the device API v1 (contracts/v1/device-api-v1.yaml): registers the
|
|
14
|
+
/// install, signs every other request with its credential, and deals with the errors that have
|
|
15
|
+
/// one right answer, so callers only see the rest:
|
|
16
|
+
///
|
|
17
|
+
/// - no credential yet: POST /installs first;
|
|
18
|
+
/// - timestamp_out_of_range: take the server's clock, retry once;
|
|
19
|
+
/// - invalid_key / invalid_signature / credential_revoked: register again, once, then retry
|
|
20
|
+
/// once (a second failure is returned as it is, so this can never loop).
|
|
21
|
+
///
|
|
22
|
+
/// Every response's Date header keeps the clock in step. Registering is serialised: requests
|
|
23
|
+
/// that find the credential missing or refused wait for one registration rather than each
|
|
24
|
+
/// doing one (found on a device: sync, config and geofences all start at once on first launch).
|
|
25
|
+
package final class DeviceApiClient: Sendable {
|
|
26
|
+
private let baseUrl: String
|
|
27
|
+
/// Nil for a device started with a credential issued outside the app: it never registers.
|
|
28
|
+
private let apiKey: String?
|
|
29
|
+
private let onCredentialRejected: @Sendable () -> Void
|
|
30
|
+
private let userAgent: String
|
|
31
|
+
private let http: any HttpClient
|
|
32
|
+
private let credentials: any CredentialStore
|
|
33
|
+
private let clock: ServerClock
|
|
34
|
+
private let installBody: @Sendable () throws -> [String: Any]
|
|
35
|
+
private let onRegistered: @Sendable ([String: Any]) -> Void
|
|
36
|
+
private let registration = AsyncMutex()
|
|
37
|
+
|
|
38
|
+
/// - Parameters:
|
|
39
|
+
/// - installBody: what POST /installs sends (api_key is added here): the install id the SDK
|
|
40
|
+
/// keeps for this installation, and what the device says about itself. It throws when the
|
|
41
|
+
/// install id can't be read yet (before the first unlock): nothing is sent then.
|
|
42
|
+
/// - onRegistered: told the install response's `data` (device and config) after each
|
|
43
|
+
/// successful registration.
|
|
44
|
+
package init(
|
|
45
|
+
baseUrl: String,
|
|
46
|
+
apiKey: String?,
|
|
47
|
+
sdkVersion: String,
|
|
48
|
+
http: any HttpClient,
|
|
49
|
+
credentials: any CredentialStore,
|
|
50
|
+
clock: ServerClock,
|
|
51
|
+
installBody: @escaping @Sendable () throws -> [String: Any],
|
|
52
|
+
onRegistered: @escaping @Sendable ([String: Any]) -> Void = { _ in },
|
|
53
|
+
onCredentialRejected: @escaping @Sendable () -> Void = {}
|
|
54
|
+
) {
|
|
55
|
+
var trimmed = baseUrl
|
|
56
|
+
while trimmed.hasSuffix("/") { trimmed.removeLast() }
|
|
57
|
+
self.baseUrl = trimmed
|
|
58
|
+
self.apiKey = apiKey
|
|
59
|
+
userAgent = "Bubbl-iOS-SDK/\(sdkVersion)"
|
|
60
|
+
self.http = http
|
|
61
|
+
self.credentials = credentials
|
|
62
|
+
self.clock = clock
|
|
63
|
+
self.installBody = installBody
|
|
64
|
+
self.onRegistered = onRegistered
|
|
65
|
+
self.onCredentialRejected = onCredentialRejected
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// POST /installs: registers (or registers again) and keeps the credential it returns.
|
|
69
|
+
/// Unsigned; the public API key identifies the company. The response tells the caller why
|
|
70
|
+
/// when it failed (e.g. own_app_not_in_plan, or a wrong API key).
|
|
71
|
+
package func register() async throws -> ApiResponse {
|
|
72
|
+
try await registration.withLock { try await self.registerLocked() }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// A signed request to `path` (from the API root, no leading slash: "api/v1/geofences").
|
|
76
|
+
/// `query` values are sent as given; `body` is sent and signed byte for byte; `headers` are
|
|
77
|
+
/// added as they are (e.g. Idempotency-Key, If-None-Match).
|
|
78
|
+
///
|
|
79
|
+
/// Throws when the request couldn't be made at all: no response (offline), or a
|
|
80
|
+
/// `DeviceApiError` (the credential can't be read yet, or couldn't be kept). Either way it's
|
|
81
|
+
/// worth trying again later; nothing was refused.
|
|
82
|
+
package func request(
|
|
83
|
+
_ method: String,
|
|
84
|
+
_ path: String,
|
|
85
|
+
query: [String: String] = [:],
|
|
86
|
+
body: String? = nil,
|
|
87
|
+
headers: [String: String] = [:]
|
|
88
|
+
) async throws -> ApiResponse {
|
|
89
|
+
if try credentialKeyId() == nil {
|
|
90
|
+
// The first to get here registers; the rest wait, find the credential, and use it.
|
|
91
|
+
let registered = try await registration.withLock { () async throws -> ApiResponse? in
|
|
92
|
+
try self.credentialKeyId() != nil ? nil : try await self.registerLocked()
|
|
93
|
+
}
|
|
94
|
+
if let registered, !registered.isSuccessful { return registered }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
var sent = try await send(method, path, query: query, body: body, headers: headers)
|
|
98
|
+
|
|
99
|
+
if sent.response.handling?.action == .correctClockAndRetry {
|
|
100
|
+
if let serverTime = JSON.int64(sent.response.json?["server_time"]), serverTime > 0 {
|
|
101
|
+
clock.sync(serverSeconds: serverTime)
|
|
102
|
+
}
|
|
103
|
+
sent = try await send(method, path, query: query, body: body, headers: headers)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if sent.response.handling?.action == .reRegister {
|
|
107
|
+
let refusedKeyId = sent.keyId
|
|
108
|
+
let registered = try await registration.withLock { () async throws -> ApiResponse? in
|
|
109
|
+
// Another request may have registered again since this one was signed (it was
|
|
110
|
+
// refused for the same reason): use that credential rather than replace it.
|
|
111
|
+
try self.credentialKeyId() != refusedKeyId ? nil : try await self.registerLocked()
|
|
112
|
+
}
|
|
113
|
+
if let registered, !registered.isSuccessful { return registered }
|
|
114
|
+
sent = try await send(method, path, query: query, body: body, headers: headers)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return sent.response
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// The credential's key id, nil when there's none; throws when it can't be read yet (never
|
|
121
|
+
/// "none" for a locked Keychain, or a working credential would be replaced).
|
|
122
|
+
private func credentialKeyId() throws -> String? {
|
|
123
|
+
switch credentials.read() {
|
|
124
|
+
case .present(let credential): credential.keyId
|
|
125
|
+
case .missing: nil
|
|
126
|
+
case .unavailable: throw DeviceApiError.credentialsLocked
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// A response, and the key id the request was signed with (nil if it couldn't be signed).
|
|
131
|
+
private struct Sent: Sendable {
|
|
132
|
+
let response: ApiResponse
|
|
133
|
+
let keyId: String?
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// What a device started with a credential gets where an install would register.
|
|
137
|
+
static let credentialRejected = #"{"message":"The device's credential was refused: start Bubbl with a new one.","code":"credential_rejected"}"#
|
|
138
|
+
|
|
139
|
+
private func registerLocked() async throws -> ApiResponse {
|
|
140
|
+
// Started with a credential issued outside the app: nothing here can renew it (there's no
|
|
141
|
+
// API key), so where an install would register, the app is told and has to start Bubbl
|
|
142
|
+
// with a new one.
|
|
143
|
+
guard let apiKey else {
|
|
144
|
+
onCredentialRejected()
|
|
145
|
+
return ApiResponse(HttpResponse(status: 401, body: Self.credentialRejected)).handled(as: ErrorHandling(.stopAndReport))
|
|
146
|
+
}
|
|
147
|
+
var install: [String: Any]
|
|
148
|
+
do {
|
|
149
|
+
install = try installBody()
|
|
150
|
+
} catch {
|
|
151
|
+
throw DeviceApiError.credentialsLocked
|
|
152
|
+
}
|
|
153
|
+
install["api_key"] = apiKey
|
|
154
|
+
let body = JSON.string(install)
|
|
155
|
+
let response = try await execute(HttpRequest(method: "POST", url: url("api/v1/installs"), headers: jsonHeaders(body: body), body: body))
|
|
156
|
+
|
|
157
|
+
if response.isSuccessful,
|
|
158
|
+
let data = response.json?["data"] as? [String: Any],
|
|
159
|
+
let credential = data["credential"] as? [String: Any],
|
|
160
|
+
let keyId = credential["key_id"] as? String, !keyId.isEmpty,
|
|
161
|
+
let secret = credential["secret"] as? String, !secret.isEmpty {
|
|
162
|
+
// Not kept, not registered: the next request registers again rather than carry on
|
|
163
|
+
// with a credential this install no longer has.
|
|
164
|
+
do {
|
|
165
|
+
try credentials.save(keyId: keyId, secret: secret)
|
|
166
|
+
} catch {
|
|
167
|
+
throw DeviceApiError.credentialNotSaved(error)
|
|
168
|
+
}
|
|
169
|
+
onRegistered(data)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// invalid_key here means the public API key itself is wrong: registering again can't fix
|
|
173
|
+
// that, so it's reported rather than retried.
|
|
174
|
+
if response.handling?.action == .reRegister {
|
|
175
|
+
return response.handled(as: ErrorHandling(.stopAndReport))
|
|
176
|
+
}
|
|
177
|
+
return response
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private func send(_ method: String, _ path: String, query: [String: String], body: String?, headers: [String: String]) async throws -> Sent {
|
|
181
|
+
let credential: SigningCredential
|
|
182
|
+
switch credentials.read() {
|
|
183
|
+
case .present(let current):
|
|
184
|
+
credential = current
|
|
185
|
+
case .missing:
|
|
186
|
+
// The credential vanished (cleared mid-request): the server's answer would be
|
|
187
|
+
// missing_signature, so say that without a round trip.
|
|
188
|
+
return Sent(response: ApiResponse(HttpResponse(status: 401, body: #"{"message":"Not registered.","code":"missing_signature"}"#)), keyId: nil)
|
|
189
|
+
case .unavailable:
|
|
190
|
+
throw DeviceApiError.credentialsLocked
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let fullUrl = url(path, query: query)
|
|
194
|
+
let timestamp = clock.nowSeconds()
|
|
195
|
+
// The server signs the request path it sees, which includes any prefix in the base URL.
|
|
196
|
+
let signedPath = String((URLComponents(string: fullUrl)?.percentEncodedPath ?? "").drop { $0 == "/" })
|
|
197
|
+
let signature = RequestSigner.sign(secret: credential.secret, method: method, path: signedPath, timestamp: Int(timestamp), body: body ?? "", query: query)
|
|
198
|
+
|
|
199
|
+
var allHeaders = jsonHeaders(body: body)
|
|
200
|
+
allHeaders.merge(headers) { _, new in new }
|
|
201
|
+
allHeaders["X-Bubbl-Key-Id"] = credential.keyId
|
|
202
|
+
allHeaders["X-Bubbl-Timestamp"] = String(timestamp)
|
|
203
|
+
allHeaders["X-Bubbl-Signature"] = signature
|
|
204
|
+
|
|
205
|
+
let response = try await execute(HttpRequest(method: method, url: fullUrl, headers: allHeaders, body: body))
|
|
206
|
+
return Sent(response: response, keyId: credential.keyId)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private func execute(_ request: HttpRequest) async throws -> ApiResponse {
|
|
210
|
+
let response = try await http.execute(request)
|
|
211
|
+
clock.sync(dateHeader: response.header("Date"))
|
|
212
|
+
let result = ApiResponse(response)
|
|
213
|
+
if let said = Self.wrongEnvironment(result) { BubblLog.error(said) }
|
|
214
|
+
return result
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/// What the log says for wrong_environment (a pk_test_ key at the Production address, or the
|
|
218
|
+
/// other way round): the server's message, and the address the key belongs to (`base_url`).
|
|
219
|
+
package static func wrongEnvironment(_ response: ApiResponse) -> String? {
|
|
220
|
+
guard response.code == "wrong_environment" else { return nil }
|
|
221
|
+
let json = response.json
|
|
222
|
+
let message = (json?["message"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "The API key and the address are for different workspaces."
|
|
223
|
+
let address = (json?["base_url"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
224
|
+
return "Bubbl: the API key and baseUrl don't match (wrong_environment). \(message)" + (address.map { " Use baseUrl \($0) with this key." } ?? "")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// The URL for `path`, with `query` in canonical order (the same encoding that's signed).
|
|
228
|
+
private func url(_ path: String, query: [String: String] = [:]) -> String {
|
|
229
|
+
let canonical = RequestSigner.canonicalQuery(query)
|
|
230
|
+
let relative = path.hasPrefix("/") ? String(path.dropFirst()) : path
|
|
231
|
+
return "\(baseUrl)/\(relative)" + (canonical.isEmpty ? "" : "?\(canonical)")
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private func jsonHeaders(body: String?) -> [String: String] {
|
|
235
|
+
var headers = ["Accept": "application/json", "User-Agent": userAgent]
|
|
236
|
+
if body != nil { headers["Content-Type"] = "application/json" }
|
|
237
|
+
return headers
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/// What the engine does about a failed request. The device API contract's errors fixture
|
|
2
|
+
/// (contracts/v1/fixtures/v1/errors.json) is the source of truth for which code gets which
|
|
3
|
+
/// action, and ErrorActionsContractTests checks this against it. Raw values are the fixture's.
|
|
4
|
+
package enum ErrorAction: String, Sendable, CaseIterable {
|
|
5
|
+
/// POST /installs again, once and one at a time, then retry.
|
|
6
|
+
case reRegister = "re_register"
|
|
7
|
+
/// Take the clock offset from server_time (or the Date header) and retry once.
|
|
8
|
+
case correctClockAndRetry = "correct_clock_and_retry"
|
|
9
|
+
/// Stop calling the API for a while (ErrorHandling.pauseHours); keep queuing.
|
|
10
|
+
case pause
|
|
11
|
+
/// PUT /device, then retry.
|
|
12
|
+
case describeDevice = "describe_device"
|
|
13
|
+
/// Drop the item and fetch geofences again: the location is gone.
|
|
14
|
+
case dropAndRefreshGeofences = "drop_and_refresh_geofences"
|
|
15
|
+
/// Discard the request or item (it can never succeed as sent) and log it.
|
|
16
|
+
case drop
|
|
17
|
+
/// Wait for Retry-After, then retry.
|
|
18
|
+
case waitAndRetry = "wait_and_retry"
|
|
19
|
+
/// Stop and surface it in diagnostics: something is misconfigured.
|
|
20
|
+
case stopAndReport = "stop_and_report"
|
|
21
|
+
/// The pairing UI shows the server's message.
|
|
22
|
+
case showMessage = "show_message"
|
|
23
|
+
/// 5xx or network failure: exponential backoff with jitter.
|
|
24
|
+
case backoff
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// An ErrorAction, with how long to pause for `.pause`.
|
|
28
|
+
package struct ErrorHandling: Sendable, Equatable {
|
|
29
|
+
package let action: ErrorAction
|
|
30
|
+
package let pauseHours: Int?
|
|
31
|
+
|
|
32
|
+
package init(_ action: ErrorAction, pauseHours: Int? = nil) {
|
|
33
|
+
self.action = action
|
|
34
|
+
self.pauseHours = pauseHours
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
package enum ErrorActions {
|
|
39
|
+
/// The handling for a response's status and error `code` (nil when the body had none). An
|
|
40
|
+
/// unknown code falls back on its status, so a newer server can't wedge an older SDK.
|
|
41
|
+
package static func forResponse(status: Int, code: String?) -> ErrorHandling {
|
|
42
|
+
switch code {
|
|
43
|
+
case "missing_signature": ErrorHandling(.stopAndReport)
|
|
44
|
+
case "timestamp_out_of_range": ErrorHandling(.correctClockAndRetry)
|
|
45
|
+
case "invalid_key", "invalid_signature", "credential_revoked": ErrorHandling(.reRegister)
|
|
46
|
+
case "workspace_paused": ErrorHandling(.pause, pauseHours: 6)
|
|
47
|
+
case "own_app_not_in_plan": ErrorHandling(.pause, pauseHours: 24)
|
|
48
|
+
// Sandbox: no room for another device awaiting approval; back off as for a paused workspace.
|
|
49
|
+
case "sandbox_pending_full": ErrorHandling(.pause, pauseHours: 6)
|
|
50
|
+
// A Sandbox key at the Production address or the other way round: only the app can fix it.
|
|
51
|
+
case "wrong_environment": ErrorHandling(.stopAndReport)
|
|
52
|
+
case "showcase_only", "unknown_notification": ErrorHandling(.drop)
|
|
53
|
+
case "device_not_registered": ErrorHandling(.describeDevice)
|
|
54
|
+
case "request_in_progress", "too_many_failed_attempts": ErrorHandling(.waitAndRetry)
|
|
55
|
+
case "unknown_location": ErrorHandling(.dropAndRefreshGeofences)
|
|
56
|
+
// The message goes back to whoever asked (pairing; Bubbl.registerTestDevice).
|
|
57
|
+
case "invalid_pairing_code", "invalid_test_device_code", "test_devices_full": ErrorHandling(.showMessage)
|
|
58
|
+
case "rate_limited": ErrorHandling(.waitAndRetry)
|
|
59
|
+
default:
|
|
60
|
+
switch status {
|
|
61
|
+
case 422: ErrorHandling(.drop)
|
|
62
|
+
case 429: ErrorHandling(.waitAndRetry)
|
|
63
|
+
case 500...: ErrorHandling(.backoff)
|
|
64
|
+
default: ErrorHandling(.stopAndReport)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/// One HTTP request as the engine sends it: the full URL, and the body's exact text.
|
|
2
|
+
package struct HttpRequest: Sendable, Equatable {
|
|
3
|
+
package var method: String
|
|
4
|
+
package var url: String
|
|
5
|
+
package var headers: [String: String]
|
|
6
|
+
package var body: String?
|
|
7
|
+
|
|
8
|
+
package init(method: String, url: String, headers: [String: String] = [:], body: String? = nil) {
|
|
9
|
+
self.method = method
|
|
10
|
+
self.url = url
|
|
11
|
+
self.headers = headers
|
|
12
|
+
self.body = body
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// The response: status, body text and headers (names as the server sent them).
|
|
17
|
+
package struct HttpResponse: Sendable, Equatable {
|
|
18
|
+
package var status: Int
|
|
19
|
+
package var body: String
|
|
20
|
+
package var headers: [String: [String]]
|
|
21
|
+
|
|
22
|
+
package init(status: Int, body: String, headers: [String: [String]] = [:]) {
|
|
23
|
+
self.status = status
|
|
24
|
+
self.body = body
|
|
25
|
+
self.headers = headers
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// A header's first value, matched without regard to case (HTTP header names aren't).
|
|
29
|
+
package func header(_ name: String) -> String? {
|
|
30
|
+
headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value.first
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Sends one request. The engine's only way out to the network, so tests swap it for a fake
|
|
35
|
+
/// and no HTTP library is forced on the app. Throws when there's no response at all (offline,
|
|
36
|
+
/// timed out); any HTTP status is a response.
|
|
37
|
+
package protocol HttpClient: Sendable {
|
|
38
|
+
func execute(_ request: HttpRequest) async throws -> HttpResponse
|
|
39
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The server's idea of "now", for request timestamps: a signed request is refused when its
|
|
4
|
+
/// timestamp is more than 300 s from the server's clock, and phone clocks drift or are set by
|
|
5
|
+
/// hand. The offset comes from each response's Date header, and from the server_time a
|
|
6
|
+
/// timestamp_out_of_range error carries.
|
|
7
|
+
package final class ServerClock: @unchecked Sendable {
|
|
8
|
+
/// Offset changes smaller than this are ignored: the Date header has one-second resolution
|
|
9
|
+
/// and a response takes time to arrive.
|
|
10
|
+
private static let toleranceSeconds: Int64 = 5
|
|
11
|
+
|
|
12
|
+
private let lock = NSLock()
|
|
13
|
+
private var offset: Int64
|
|
14
|
+
private let deviceNowSeconds: @Sendable () -> Int64
|
|
15
|
+
private let onChange: @Sendable (Int64) -> Void
|
|
16
|
+
|
|
17
|
+
/// - Parameter onChange: told each new offset, so it can be kept across launches.
|
|
18
|
+
package init(
|
|
19
|
+
initialOffsetSeconds: Int64 = 0,
|
|
20
|
+
deviceNowSeconds: @escaping @Sendable () -> Int64 = { Int64(Date().timeIntervalSince1970) },
|
|
21
|
+
onChange: @escaping @Sendable (Int64) -> Void = { _ in }
|
|
22
|
+
) {
|
|
23
|
+
offset = initialOffsetSeconds
|
|
24
|
+
self.deviceNowSeconds = deviceNowSeconds
|
|
25
|
+
self.onChange = onChange
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
package var offsetSeconds: Int64 {
|
|
29
|
+
lock.sync { offset }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Unix seconds as the server would read them now.
|
|
33
|
+
package func nowSeconds() -> Int64 {
|
|
34
|
+
deviceNowSeconds() + offsetSeconds
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Adopt the server's time.
|
|
38
|
+
package func sync(serverSeconds: Int64) {
|
|
39
|
+
let candidate = serverSeconds - deviceNowSeconds()
|
|
40
|
+
let changed: Bool = lock.sync {
|
|
41
|
+
guard abs(candidate - offset) >= Self.toleranceSeconds else { return false }
|
|
42
|
+
offset = candidate
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
if changed { onChange(candidate) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// sync(serverSeconds:) from an HTTP Date header (RFC 1123); an absent or unreadable one
|
|
49
|
+
/// changes nothing.
|
|
50
|
+
package func sync(dateHeader: String?) {
|
|
51
|
+
guard let dateHeader, let date = Self.httpDate(dateHeader) else { return }
|
|
52
|
+
sync(serverSeconds: Int64(date.timeIntervalSince1970))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Parses "Sun, 06 Nov 1994 08:49:37 GMT". One formatter for every response, used one at a
|
|
56
|
+
/// time (DateFormatter isn't safe to share across threads everywhere the core runs).
|
|
57
|
+
package static func httpDate(_ value: String) -> Date? {
|
|
58
|
+
httpDateLock.sync { httpDateFormatter.date(from: value.trimmingCharacters(in: .whitespaces)) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private static let httpDateLock = NSLock()
|
|
62
|
+
private static let httpDateFormatter: DateFormatter = {
|
|
63
|
+
let formatter = DateFormatter()
|
|
64
|
+
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
65
|
+
formatter.timeZone = TimeZone(identifier: "GMT")
|
|
66
|
+
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
|
67
|
+
return formatter
|
|
68
|
+
}()
|
|
69
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// GET /config's data (every field is always present, per the contract), read leniently.
|
|
4
|
+
package struct SdkConfig: Sendable, Hashable {
|
|
5
|
+
package static let defaultRefreshSeconds: Int64 = 3_600
|
|
6
|
+
|
|
7
|
+
package let json: JSONValue
|
|
8
|
+
|
|
9
|
+
package init(_ json: JSONValue) {
|
|
10
|
+
self.json = json
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
package var enabled: Bool {
|
|
14
|
+
if case .bool(let value)? = json["enabled"] { value } else { true }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// Whether the server has this device's push token (device.push_token_registered). False while
|
|
18
|
+
/// the app holds one means the server dropped it (Apple or FCM called it dead, or it was sent
|
|
19
|
+
/// for the wrong APNs environment); nil before the device has described itself.
|
|
20
|
+
package var pushTokenRegistered: Bool? {
|
|
21
|
+
if case .bool(let value)? = json["device"]?["push_token_registered"] { value } else { nil }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
package var minimumVersion: String? { json["sdk"]?["minimum_version"]?.nonEmptyString }
|
|
25
|
+
|
|
26
|
+
package var logLevel: String { json["sdk"]?["log_level"]?.nonEmptyString ?? "warning" }
|
|
27
|
+
|
|
28
|
+
/// From one minute to a week, whatever the server says.
|
|
29
|
+
package var configRefreshSeconds: Int64 {
|
|
30
|
+
json["sync"]?["config_refresh_seconds"]?.int64(in: 60...604_800) ?? Self.defaultRefreshSeconds
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// From 1 to the queue's 100.
|
|
34
|
+
package var maxEventsPerRequest: Int {
|
|
35
|
+
json["sync"]?["max_events_per_request"]?.int64(in: 1...Int64(EventQueue.batchSize)).map { Int($0) } ?? EventQueue.batchSize
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// The dashboard's privacy view: when to explain before asking for a permission.
|
|
39
|
+
package var privacyNotice: String {
|
|
40
|
+
let notice = json["privacy"]?["notice"]?.stringValue ?? ""
|
|
41
|
+
return ["automatic", "always", "never"].contains(notice) ? notice : "automatic"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// The dashboard's own words for the privacy view, if it has set any.
|
|
45
|
+
package var privacyText: String? {
|
|
46
|
+
json["privacy"]?["text"]?.nonEmptyString.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// The workspace's privacy policy, linked from the privacy view.
|
|
50
|
+
package var privacyUrl: String? {
|
|
51
|
+
json["privacy"]?["callback_url"]?.nonEmptyString.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Whether `sdkVersion` is at least the minimum the workspace allows (none: any).
|
|
55
|
+
package func supports(_ sdkVersion: String) -> Bool {
|
|
56
|
+
guard let minimumVersion else { return true }
|
|
57
|
+
return Versions.compare(sdkVersion, minimumVersion) >= 0
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
package enum ConfigResult: Sendable, Equatable {
|
|
62
|
+
case updated
|
|
63
|
+
case unchanged
|
|
64
|
+
case notDue
|
|
65
|
+
case failed(ApiFailure)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// The runtime configuration: what POST /installs returned, then GET /config every
|
|
69
|
+
/// config_refresh_seconds (conditional, If-None-Match). Kept on disk, so the engine starts with the
|
|
70
|
+
/// last known configuration when offline.
|
|
71
|
+
package final class ConfigSync: Sendable {
|
|
72
|
+
package struct Saved: Codable, Sendable, Equatable {
|
|
73
|
+
package var config: JSONValue
|
|
74
|
+
package var etag: String?
|
|
75
|
+
package var fetchedAtSeconds: Int64
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private let api: DeviceApiClient
|
|
79
|
+
private let clock: ServerClock
|
|
80
|
+
private let store: any ValueStore<Saved>
|
|
81
|
+
private let lock = AsyncMutex()
|
|
82
|
+
/// The saved config, once read (it's asked for often: enabled, the log level, the batch size).
|
|
83
|
+
private let cache = Cached<Saved>()
|
|
84
|
+
|
|
85
|
+
package init(api: DeviceApiClient, clock: ServerClock, store: any ValueStore<Saved>) {
|
|
86
|
+
self.api = api
|
|
87
|
+
self.clock = clock
|
|
88
|
+
self.store = store
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// The last known configuration; nil before the first, or while it can't be read.
|
|
92
|
+
package var current: SdkConfig? {
|
|
93
|
+
(try? load())?.map { SdkConfig($0.config) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// The config that came with a registration.
|
|
97
|
+
package func registered(_ config: JSONValue) throws {
|
|
98
|
+
try save(Saved(config: config, etag: nil, fetchedAtSeconds: clock.nowSeconds()))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// The workspace block from POST /test-device, put into the saved config (where GET /config
|
|
102
|
+
/// would bring it), so the device reads as approved without another request.
|
|
103
|
+
package func setWorkspace(_ workspace: JSONValue) async throws {
|
|
104
|
+
try await lock.withLock {
|
|
105
|
+
guard var saved = try self.load(), case .object(var fields) = saved.config else { return }
|
|
106
|
+
fields["workspace"] = workspace
|
|
107
|
+
saved.config = .object(fields)
|
|
108
|
+
try self.save(saved)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Forget it (deleteMyData): the next registration brings the config afresh.
|
|
113
|
+
package func forget() throws {
|
|
114
|
+
try store.delete()
|
|
115
|
+
cache.set(nil)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private func load() throws -> Saved? {
|
|
119
|
+
try cache.value { try store.load() }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private func save(_ saved: Saved) throws {
|
|
123
|
+
try store.save(saved)
|
|
124
|
+
cache.set(saved)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// GET /config when it's due: every config_refresh_seconds, or sooner with `maxAgeSeconds` (the
|
|
128
|
+
/// app coming to the front, so a change such as a Sandbox approval is seen on the next open).
|
|
129
|
+
/// Conditional, so an unchanged config costs a 304.
|
|
130
|
+
package func refresh(force: Bool = false, maxAgeSeconds: Int64? = nil) async -> ConfigResult {
|
|
131
|
+
await lock.withLock { await self.refreshLocked(force: force, maxAgeSeconds: maxAgeSeconds) }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func refreshLocked(force: Bool, maxAgeSeconds: Int64?) async -> ConfigResult {
|
|
135
|
+
let saved: Saved?
|
|
136
|
+
do {
|
|
137
|
+
saved = try load()
|
|
138
|
+
} catch {
|
|
139
|
+
return .failed(.backoff)
|
|
140
|
+
}
|
|
141
|
+
let refreshSeconds = SdkConfig(saved?.config ?? .null).configRefreshSeconds
|
|
142
|
+
if !force, let saved, clock.nowSeconds() - saved.fetchedAtSeconds < min(refreshSeconds, maxAgeSeconds ?? refreshSeconds) {
|
|
143
|
+
return .notDue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let response: ApiResponse
|
|
147
|
+
do {
|
|
148
|
+
response = try await api.request("GET", "api/v1/config", headers: saved?.etag.map { ["If-None-Match": $0] } ?? [:])
|
|
149
|
+
} catch {
|
|
150
|
+
return .failed(.backoff)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
do {
|
|
154
|
+
if response.status == 304, var saved {
|
|
155
|
+
saved.fetchedAtSeconds = clock.nowSeconds()
|
|
156
|
+
try save(saved)
|
|
157
|
+
return .unchanged
|
|
158
|
+
}
|
|
159
|
+
struct Body: Decodable { let data: JSONValue }
|
|
160
|
+
if response.status == 200, let body = response.decode(Body.self), case .object = body.data {
|
|
161
|
+
try save(Saved(config: body.data, etag: response.header("ETag"), fetchedAtSeconds: clock.nowSeconds()))
|
|
162
|
+
return .updated
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
return .failed(.backoff)
|
|
166
|
+
}
|
|
167
|
+
return response.isSuccessful ? .failed(.backoff) : .failed(ApiFailure.of(response))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Semantic version order: numbers compared numerically, and a pre-release before its release.
|
|
172
|
+
package enum Versions {
|
|
173
|
+
package static func compare(_ a: String, _ b: String) -> Int {
|
|
174
|
+
let (coreA, preA) = split(a)
|
|
175
|
+
let (coreB, preB) = split(b)
|
|
176
|
+
for i in 0..<max(coreA.count, coreB.count) {
|
|
177
|
+
let x = i < coreA.count ? coreA[i] : 0
|
|
178
|
+
let y = i < coreB.count ? coreB[i] : 0
|
|
179
|
+
if x != y { return x < y ? -1 : 1 }
|
|
180
|
+
}
|
|
181
|
+
switch (preA, preB) {
|
|
182
|
+
case (nil, nil): return 0
|
|
183
|
+
case (nil, _): return 1
|
|
184
|
+
case (_, nil): return -1
|
|
185
|
+
case let (a?, b?): return comparePre(a, b)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private static func split(_ version: String) -> ([Int], String?) {
|
|
190
|
+
var clean = version.trimmingCharacters(in: .whitespaces)
|
|
191
|
+
if clean.hasPrefix("v") { clean.removeFirst() }
|
|
192
|
+
if let plus = clean.firstIndex(of: "+") { clean = String(clean[..<plus]) }
|
|
193
|
+
let parts = clean.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
|
|
194
|
+
let core = parts[0].split(separator: ".").map { Int($0) ?? 0 }
|
|
195
|
+
let pre = parts.count > 1 && !parts[1].isEmpty ? String(parts[1]) : nil
|
|
196
|
+
return (core, pre)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private static func comparePre(_ a: String, _ b: String) -> Int {
|
|
200
|
+
let partsA = a.split(separator: ".")
|
|
201
|
+
let partsB = b.split(separator: ".")
|
|
202
|
+
for i in 0..<min(partsA.count, partsB.count) {
|
|
203
|
+
let x = Int(partsA[i])
|
|
204
|
+
let y = Int(partsB[i])
|
|
205
|
+
let diff: Int
|
|
206
|
+
switch (x, y) {
|
|
207
|
+
case let (x?, y?): diff = x == y ? 0 : (x < y ? -1 : 1)
|
|
208
|
+
case (_?, nil): diff = -1
|
|
209
|
+
case (nil, _?): diff = 1
|
|
210
|
+
default: diff = partsA[i] == partsB[i] ? 0 : (partsA[i] < partsB[i] ? -1 : 1)
|
|
211
|
+
}
|
|
212
|
+
if diff != 0 { return diff }
|
|
213
|
+
}
|
|
214
|
+
return partsA.count == partsB.count ? 0 : (partsA.count < partsB.count ? -1 : 1)
|
|
215
|
+
}
|
|
216
|
+
}
|