@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,90 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// A device API v1 response: its status, body and headers, and — for an error — what the engine
|
|
4
|
+
/// should do about it (`handling`, from the contract's error table).
|
|
5
|
+
package struct ApiResponse: Sendable {
|
|
6
|
+
package let status: Int
|
|
7
|
+
package let body: String
|
|
8
|
+
private let raw: HttpResponse
|
|
9
|
+
private let forcedHandling: ErrorHandling?
|
|
10
|
+
|
|
11
|
+
package init(_ raw: HttpResponse, handledAs forcedHandling: ErrorHandling? = nil) {
|
|
12
|
+
status = raw.status
|
|
13
|
+
body = raw.body
|
|
14
|
+
self.raw = raw
|
|
15
|
+
self.forcedHandling = forcedHandling
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
package var isSuccessful: Bool { (200...299).contains(status) || status == 304 }
|
|
19
|
+
|
|
20
|
+
/// The body as a JSON object, or nil when it isn't one (a 304, or a proxy's error page).
|
|
21
|
+
package var json: [String: Any]? { JSON.object(body) }
|
|
22
|
+
|
|
23
|
+
/// The body decoded as `T`, or nil when it isn't one. Codable reads the same on every
|
|
24
|
+
/// platform, so parsing goes through here rather than `json`.
|
|
25
|
+
package func decode<T: Decodable>(_ type: T.Type) -> T? {
|
|
26
|
+
try? JSONDecoder().decode(type, from: Data(body.utf8))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// The error's stable `code`, when the body has one.
|
|
30
|
+
package var code: String? {
|
|
31
|
+
(json?["code"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// What to do about a failed response; nil for a success.
|
|
35
|
+
package var handling: ErrorHandling? {
|
|
36
|
+
isSuccessful ? nil : forcedHandling ?? ErrorActions.forResponse(status: status, code: code)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// The same response, handled as `handling` says instead of by its code.
|
|
40
|
+
package func handled(as handling: ErrorHandling) -> ApiResponse {
|
|
41
|
+
ApiResponse(raw, handledAs: handling)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
package func header(_ name: String) -> String? { raw.header(name) }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Why a call to the device API didn't get through, reduced to what whoever scheduled it (a
|
|
48
|
+
/// background task, an app resume) has to do next. Built from the contract's error table.
|
|
49
|
+
package enum ApiFailure: Sendable, Equatable {
|
|
50
|
+
/// Try again later with exponential backoff (5xx, no network).
|
|
51
|
+
case backoff
|
|
52
|
+
/// The server asked for a wait (429, request_in_progress).
|
|
53
|
+
case retryAfter(seconds: Int)
|
|
54
|
+
/// Stop calling the API for a while (workspace paused, free tier); keep queuing.
|
|
55
|
+
case pause(hours: Int, code: String?)
|
|
56
|
+
/// The server doesn't know this device yet: PUT /device, then try again.
|
|
57
|
+
case describeDevice
|
|
58
|
+
/// The request can never succeed as sent: discard it (and refetch geofences, if said).
|
|
59
|
+
case drop(refreshGeofences: Bool, status: Int, code: String?)
|
|
60
|
+
/// Something only a developer can fix (misconfiguration): surface it in diagnostics.
|
|
61
|
+
case stopped(status: Int, code: String?)
|
|
62
|
+
|
|
63
|
+
private static let defaultRetrySeconds = 30
|
|
64
|
+
private static let defaultPauseHours = 1
|
|
65
|
+
|
|
66
|
+
/// What a failed `response` means for the caller. Never called with a success.
|
|
67
|
+
package static func of(_ response: ApiResponse) -> ApiFailure {
|
|
68
|
+
guard let handling = response.handling else { return .stopped(status: response.status, code: response.code) }
|
|
69
|
+
|
|
70
|
+
switch handling.action {
|
|
71
|
+
case .backoff:
|
|
72
|
+
return .backoff
|
|
73
|
+
case .waitAndRetry:
|
|
74
|
+
let seconds = response.header("Retry-After").flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }
|
|
75
|
+
return .retryAfter(seconds: max(seconds ?? defaultRetrySeconds, 1))
|
|
76
|
+
case .pause:
|
|
77
|
+
return .pause(hours: handling.pauseHours ?? defaultPauseHours, code: response.code)
|
|
78
|
+
case .describeDevice:
|
|
79
|
+
return .describeDevice
|
|
80
|
+
case .drop:
|
|
81
|
+
return .drop(refreshGeofences: false, status: response.status, code: response.code)
|
|
82
|
+
case .dropAndRefreshGeofences:
|
|
83
|
+
return .drop(refreshGeofences: true, status: response.status, code: response.code)
|
|
84
|
+
// The client has already registered again or corrected the clock once; what's left of
|
|
85
|
+
// those, and everything else, needs a person.
|
|
86
|
+
case .reRegister, .correctClockAndRetry, .stopAndReport, .showMessage:
|
|
87
|
+
return .stopped(status: response.status, code: response.code)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The install's credential, read together so a registration in between can't mix old and new.
|
|
4
|
+
package struct SigningCredential: Sendable, Equatable {
|
|
5
|
+
package let keyId: String
|
|
6
|
+
package let secret: String
|
|
7
|
+
|
|
8
|
+
package init(keyId: String, secret: String) {
|
|
9
|
+
self.keyId = keyId
|
|
10
|
+
self.secret = secret
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/// What reading the credential found. `unavailable` is not `missing`: before the first unlock
|
|
15
|
+
/// after a reboot iOS can relaunch the app (for a region event) while the Keychain can't be read
|
|
16
|
+
/// yet. Taking that for "not registered" would register again and replace a working credential.
|
|
17
|
+
package enum CredentialRead: Sendable, Equatable {
|
|
18
|
+
case present(SigningCredential)
|
|
19
|
+
case missing
|
|
20
|
+
case unavailable
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Where the install's signing credential lives: the Keychain on a device
|
|
24
|
+
/// (AfterFirstUnlockThisDeviceOnly), memory in tests.
|
|
25
|
+
package protocol CredentialStore: Sendable {
|
|
26
|
+
/// The key id and secret, read together.
|
|
27
|
+
func read() -> CredentialRead
|
|
28
|
+
|
|
29
|
+
/// Keep a new credential, replacing any old one. Throws when it couldn't be kept (the Keychain
|
|
30
|
+
/// refused it): the registration it came from then doesn't count.
|
|
31
|
+
func save(keyId: String, secret: String) throws
|
|
32
|
+
|
|
33
|
+
/// Forget the credential (deleteMyData, or an earlier installation's). Throws when it couldn't
|
|
34
|
+
/// be forgotten (the Keychain refused), so nothing claims the device's data is gone when it isn't.
|
|
35
|
+
func clear() throws
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// A credential kept only while this install's id is. On iOS the Keychain outlives the app and its
|
|
39
|
+
/// files don't: after a reinstall the old install's credential would sign for a new install id.
|
|
40
|
+
/// A credential with no install id beside it (none saved, not merely unreadable) belongs to an
|
|
41
|
+
/// earlier installation, so it's forgotten and this install registers as itself, as on Android,
|
|
42
|
+
/// where uninstalling wipes the Keystore. Checked once per process: from then on a credential is
|
|
43
|
+
/// only saved by a registration, which makes the install id first.
|
|
44
|
+
package final class InstallBoundCredentialStore: CredentialStore, @unchecked Sendable {
|
|
45
|
+
private let base: any CredentialStore
|
|
46
|
+
private let installId: any ValueStore<String>
|
|
47
|
+
private let lock = NSLock()
|
|
48
|
+
private var checked = false
|
|
49
|
+
|
|
50
|
+
package init(_ base: any CredentialStore, installId: any ValueStore<String>) {
|
|
51
|
+
self.base = base
|
|
52
|
+
self.installId = installId
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
package func read() -> CredentialRead {
|
|
56
|
+
lock.sync {
|
|
57
|
+
let read = base.read()
|
|
58
|
+
guard !checked, case .present = read else {
|
|
59
|
+
if read == .missing { checked = true }
|
|
60
|
+
return read
|
|
61
|
+
}
|
|
62
|
+
let id: String?
|
|
63
|
+
do {
|
|
64
|
+
id = try installId.load()
|
|
65
|
+
} catch {
|
|
66
|
+
return .unavailable
|
|
67
|
+
}
|
|
68
|
+
if let id, !id.isEmpty {
|
|
69
|
+
checked = true
|
|
70
|
+
return read
|
|
71
|
+
}
|
|
72
|
+
do {
|
|
73
|
+
try base.clear()
|
|
74
|
+
} catch {
|
|
75
|
+
// Still there: not this install's, and not gone either. Asked again next time.
|
|
76
|
+
return .unavailable
|
|
77
|
+
}
|
|
78
|
+
checked = true
|
|
79
|
+
BubblLog.info("A credential left by an earlier installation of the app was forgotten")
|
|
80
|
+
return .missing
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
package func save(keyId: String, secret: String) throws {
|
|
85
|
+
try base.save(keyId: keyId, secret: secret)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
package func clear() throws {
|
|
89
|
+
try base.clear()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// A CredentialStore in memory: for tests, and for nothing else.
|
|
94
|
+
package final class InMemoryCredentialStore: CredentialStore, @unchecked Sendable {
|
|
95
|
+
private let lock = NSLock()
|
|
96
|
+
private var credential: SigningCredential?
|
|
97
|
+
|
|
98
|
+
package init() {}
|
|
99
|
+
|
|
100
|
+
/// The current key id, for tests.
|
|
101
|
+
package func keyId() -> String? {
|
|
102
|
+
lock.sync { credential?.keyId }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
package func read() -> CredentialRead {
|
|
106
|
+
lock.sync { credential.map(CredentialRead.present) ?? .missing }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
package func save(keyId: String, secret: String) throws {
|
|
110
|
+
lock.sync { credential = SigningCredential(keyId: keyId, secret: secret) }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
package func clear() throws {
|
|
114
|
+
lock.sync { credential = nil }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -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
|
+
}
|