@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,39 @@
|
|
|
1
|
+
/// A lock for async code: one caller at a time runs the body, the rest wait their turn (in
|
|
2
|
+
/// order) without blocking a thread. An actor alone isn't enough, since an actor lets other
|
|
3
|
+
/// calls in whenever the running one awaits; registering the install must not interleave.
|
|
4
|
+
package actor AsyncMutex {
|
|
5
|
+
private var locked = false
|
|
6
|
+
private var waiters: [CheckedContinuation<Void, Never>] = []
|
|
7
|
+
|
|
8
|
+
package init() {}
|
|
9
|
+
|
|
10
|
+
/// Runs `body` holding the lock, and releases it however `body` ends.
|
|
11
|
+
package nonisolated func withLock<T: Sendable>(_ body: @Sendable () async throws -> T) async rethrows -> T {
|
|
12
|
+
await lock()
|
|
13
|
+
do {
|
|
14
|
+
let result = try await body()
|
|
15
|
+
await unlock()
|
|
16
|
+
return result
|
|
17
|
+
} catch {
|
|
18
|
+
await unlock()
|
|
19
|
+
throw error
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private func lock() async {
|
|
24
|
+
guard locked else {
|
|
25
|
+
locked = true
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
await withCheckedContinuation { waiters.append($0) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private func unlock() {
|
|
32
|
+
if waiters.isEmpty {
|
|
33
|
+
locked = false
|
|
34
|
+
} else {
|
|
35
|
+
// The lock passes straight to the next waiter: it stays held.
|
|
36
|
+
waiters.removeFirst().resume()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// How much the SDK says (BubblOptions.logLevel), most important first.
|
|
4
|
+
package enum LogSeverity: Int, Sendable, Comparable, CaseIterable {
|
|
5
|
+
case error, warning, info, debug
|
|
6
|
+
|
|
7
|
+
package static func < (lhs: LogSeverity, rhs: LogSeverity) -> Bool { lhs.rawValue < rhs.rawValue }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// The SDK's log, as Android's BubblLog: one place the core reports whatever it drops or holds
|
|
11
|
+
/// back (a question type a newer dashboard added, a refused event, a batch the server rejected),
|
|
12
|
+
/// so a developer can see why. Silent until the iOS layer points it at os.Logger and sets the
|
|
13
|
+
/// level BubblOptions asks for; a message below that level is never even built.
|
|
14
|
+
///
|
|
15
|
+
/// **What a message may say.** It can end up in Console and sysdiagnose for anyone holding the
|
|
16
|
+
/// device, so: ids, types, counts, codes and statuses only. Never coordinates or fixes, push
|
|
17
|
+
/// tokens, the credential or its secret, survey answers, or the app's own event properties.
|
|
18
|
+
package enum BubblLog {
|
|
19
|
+
private static let lock = NSLock()
|
|
20
|
+
nonisolated(unsafe) private static var sink: @Sendable (LogSeverity, String) -> Void = { _, _ in }
|
|
21
|
+
nonisolated(unsafe) private static var level: LogSeverity = .warning
|
|
22
|
+
nonisolated(unsafe) private static var lastErrorMessage: String?
|
|
23
|
+
|
|
24
|
+
/// Where messages go from now on (the iOS layer, or a test), and from which level up.
|
|
25
|
+
package static func setSink(level: LogSeverity = .debug, _ sink: @escaping @Sendable (LogSeverity, String) -> Void) {
|
|
26
|
+
lock.sync {
|
|
27
|
+
self.sink = sink
|
|
28
|
+
self.level = level
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// The level messages are kept from (BubblOptions.logLevel).
|
|
33
|
+
package static func setLevel(_ level: LogSeverity) {
|
|
34
|
+
lock.sync { self.level = level }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// The last error logged, for diagnostics (kept whatever the level).
|
|
38
|
+
package static var lastError: String? { lock.sync { lastErrorMessage } }
|
|
39
|
+
|
|
40
|
+
package static func error(_ message: @autoclosure () -> String) { log(.error, message) }
|
|
41
|
+
package static func warning(_ message: @autoclosure () -> String) { log(.warning, message) }
|
|
42
|
+
package static func info(_ message: @autoclosure () -> String) { log(.info, message) }
|
|
43
|
+
package static func debug(_ message: @autoclosure () -> String) { log(.debug, message) }
|
|
44
|
+
|
|
45
|
+
private static func log(_ level: LogSeverity, _ message: () -> String) {
|
|
46
|
+
let (sink, wanted) = lock.sync { (self.sink, level <= self.level) }
|
|
47
|
+
guard wanted || level == .error else { return }
|
|
48
|
+
let text = message()
|
|
49
|
+
if level == .error { lock.sync { lastErrorMessage = text } }
|
|
50
|
+
if wanted { sink(level, text) }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// JSON as the device API speaks it, read loosely: a body that isn't the expected shape is nil
|
|
4
|
+
/// rather than an error, since a proxy's error page or an empty 304 must not throw.
|
|
5
|
+
package enum JSON {
|
|
6
|
+
/// The object in `text`, or nil when it isn't a JSON object.
|
|
7
|
+
package static func object(_ text: String) -> [String: Any]? {
|
|
8
|
+
guard let data = text.data(using: .utf8) else { return nil }
|
|
9
|
+
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// A JSON number as Int64, however the platform's JSONSerialization boxed it (NSNumber on
|
|
13
|
+
/// Apple platforms, Swift numbers elsewhere); nil when it isn't a number.
|
|
14
|
+
package static func int64(_ value: Any?) -> Int64? {
|
|
15
|
+
switch value {
|
|
16
|
+
case let value as Int: Int64(value)
|
|
17
|
+
case let value as Int64: value
|
|
18
|
+
case let value as Double: Int64(exactly: value.rounded(.towardZero))
|
|
19
|
+
case let value as NSNumber: value.int64Value
|
|
20
|
+
default: nil
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// `value` (a dictionary or array of JSON values) as compact JSON text.
|
|
25
|
+
package static func string(_ value: Any) -> String {
|
|
26
|
+
guard JSONSerialization.isValidJSONObject(value),
|
|
27
|
+
let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
|
|
28
|
+
else { return "{}" }
|
|
29
|
+
return String(decoding: data, as: UTF8.self)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/// Any JSON value, as Codable: for the parts of the device API that carry free-form JSON (an
|
|
2
|
+
/// event's `data`, a rejection's `errors`). JSONEncoder and JSONDecoder read and write it the same
|
|
3
|
+
/// way on every platform the core runs on, unlike JSONSerialization's boxed numbers.
|
|
4
|
+
package enum JSONValue: Sendable, Hashable, Codable {
|
|
5
|
+
case null
|
|
6
|
+
case bool(Bool)
|
|
7
|
+
case int(Int64)
|
|
8
|
+
case double(Double)
|
|
9
|
+
case string(String)
|
|
10
|
+
case array([JSONValue])
|
|
11
|
+
case object([String: JSONValue])
|
|
12
|
+
|
|
13
|
+
package init(from decoder: any Decoder) throws {
|
|
14
|
+
let container = try decoder.singleValueContainer()
|
|
15
|
+
if container.decodeNil() {
|
|
16
|
+
self = .null
|
|
17
|
+
} else if let value = try? container.decode(Bool.self) {
|
|
18
|
+
self = .bool(value)
|
|
19
|
+
} else if let value = try? container.decode(Int64.self) {
|
|
20
|
+
self = .int(value)
|
|
21
|
+
} else if let value = try? container.decode(Double.self) {
|
|
22
|
+
self = .double(value)
|
|
23
|
+
} else if let value = try? container.decode(String.self) {
|
|
24
|
+
self = .string(value)
|
|
25
|
+
} else if let value = try? container.decode([JSONValue].self) {
|
|
26
|
+
self = .array(value)
|
|
27
|
+
} else {
|
|
28
|
+
self = .object(try container.decode([String: JSONValue].self))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
package func encode(to encoder: any Encoder) throws {
|
|
33
|
+
var container = encoder.singleValueContainer()
|
|
34
|
+
switch self {
|
|
35
|
+
case .null: try container.encodeNil()
|
|
36
|
+
case .bool(let value): try container.encode(value)
|
|
37
|
+
case .int(let value): try container.encode(value)
|
|
38
|
+
case .double(let value): try container.encode(value)
|
|
39
|
+
case .string(let value): try container.encode(value)
|
|
40
|
+
case .array(let value): try container.encode(value)
|
|
41
|
+
case .object(let value): try container.encode(value)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// The value at `key`, when this is an object.
|
|
46
|
+
package subscript(key: String) -> JSONValue? {
|
|
47
|
+
if case .object(let object) = self { object[key] } else { nil }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
package var stringValue: String? {
|
|
51
|
+
if case .string(let value) = self { value } else { nil }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// A number as a whole Int64, never trapping: fractions are cut, and values beyond Int64
|
|
55
|
+
/// (1e30 from a server) are clamped. Nil for anything that isn't a finite number.
|
|
56
|
+
package var int64: Int64? {
|
|
57
|
+
switch self {
|
|
58
|
+
case .int(let value): return value
|
|
59
|
+
case .double(let value):
|
|
60
|
+
guard value.isFinite else { return nil }
|
|
61
|
+
let whole = value.rounded(.towardZero)
|
|
62
|
+
if let exact = Int64(exactly: whole) { return exact }
|
|
63
|
+
return whole > 0 ? .max : .min
|
|
64
|
+
default: return nil
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// int64, clamped to `range`: for a server number that sets a limit or an interval.
|
|
69
|
+
package func int64(in range: ClosedRange<Int64>) -> Int64? {
|
|
70
|
+
int64.map { min(max($0, range.lowerBound), range.upperBound) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Whether every number in it is finite: JSON has no NaN or infinity, so JSONEncoder refuses
|
|
74
|
+
/// them (Android's JSONObject does too).
|
|
75
|
+
package var isEncodable: Bool {
|
|
76
|
+
switch self {
|
|
77
|
+
case .double(let value): value.isFinite
|
|
78
|
+
case .array(let values): values.allSatisfy(\.isEncodable)
|
|
79
|
+
case .object(let values): values.values.allSatisfy(\.isEncodable)
|
|
80
|
+
case .null, .bool, .int, .string: true
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
extension JSONValue: ExpressibleByStringLiteral, ExpressibleByIntegerLiteral, ExpressibleByBooleanLiteral,
|
|
86
|
+
ExpressibleByFloatLiteral, ExpressibleByDictionaryLiteral, ExpressibleByArrayLiteral, ExpressibleByNilLiteral {
|
|
87
|
+
package init(stringLiteral value: String) { self = .string(value) }
|
|
88
|
+
package init(integerLiteral value: Int64) { self = .int(value) }
|
|
89
|
+
package init(booleanLiteral value: Bool) { self = .bool(value) }
|
|
90
|
+
package init(floatLiteral value: Double) { self = .double(value) }
|
|
91
|
+
package init(dictionaryLiteral elements: (String, JSONValue)...) { self = .object(Dictionary(uniqueKeysWithValues: elements)) }
|
|
92
|
+
package init(arrayLiteral elements: JSONValue...) { self = .array(elements) }
|
|
93
|
+
package init(nilLiteral: ()) { self = .null }
|
|
94
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
extension NSLock {
|
|
4
|
+
/// Runs `body` holding the lock. (Foundation's own `withLock` isn't on every platform the
|
|
5
|
+
/// core builds for.)
|
|
6
|
+
package func sync<T>(_ body: () throws -> T) rethrows -> T {
|
|
7
|
+
lock()
|
|
8
|
+
defer { unlock() }
|
|
9
|
+
return try body()
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// A small piece of the engine's state kept between launches (what's been shown, the config, the
|
|
4
|
+
/// device's segments…). `load` is nil when nothing has been saved yet.
|
|
5
|
+
package protocol ValueStore<Value>: Sendable {
|
|
6
|
+
associatedtype Value: Codable & Sendable
|
|
7
|
+
func load() throws -> Value?
|
|
8
|
+
func save(_ value: Value) throws
|
|
9
|
+
func delete() throws
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// The file couldn't be read or written, so it did neither. Before the first unlock after a reboot
|
|
13
|
+
/// iOS keeps protected files closed; an unreadable file is never taken for "nothing saved".
|
|
14
|
+
package struct ValueStoreUnavailable: Error {
|
|
15
|
+
package let underlying: any Error
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// A value read once and then kept in memory, for state that's asked for often. Only a successful
|
|
19
|
+
/// read is kept: one that failed (the file locked) is tried again next time.
|
|
20
|
+
package final class Cached<Value: Sendable>: @unchecked Sendable {
|
|
21
|
+
private let lock = NSLock()
|
|
22
|
+
private var loaded = false
|
|
23
|
+
private var stored: Value?
|
|
24
|
+
|
|
25
|
+
package init() {}
|
|
26
|
+
|
|
27
|
+
/// The kept value, or `load`'s result (kept) the first time.
|
|
28
|
+
package func value(_ load: () throws -> Value?) rethrows -> Value? {
|
|
29
|
+
try lock.sync {
|
|
30
|
+
if loaded { return stored }
|
|
31
|
+
let value = try load()
|
|
32
|
+
stored = value
|
|
33
|
+
loaded = true
|
|
34
|
+
return value
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
package func set(_ value: Value?) {
|
|
39
|
+
lock.sync {
|
|
40
|
+
stored = value
|
|
41
|
+
loaded = true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// A ValueStore in memory: for tests.
|
|
47
|
+
package final class InMemoryValueStore<Value: Codable & Sendable>: ValueStore, @unchecked Sendable {
|
|
48
|
+
private let lock = NSLock()
|
|
49
|
+
private var value: Value?
|
|
50
|
+
|
|
51
|
+
package init(_ value: Value? = nil) {
|
|
52
|
+
self.value = value
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
package func load() -> Value? { lock.sync { value } }
|
|
56
|
+
package func save(_ value: Value) { lock.sync { self.value = value } }
|
|
57
|
+
package func delete() { lock.sync { value = nil } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// A ValueStore as one JSON file, written atomically (on iOS, with file protection). A missing
|
|
61
|
+
/// file is nothing saved, and so is one that reads but doesn't decode (from an older build, or
|
|
62
|
+
/// corrupt); one that can't be read at all throws ValueStoreUnavailable.
|
|
63
|
+
package final class FileValueStore<Value: Codable & Sendable>: ValueStore, @unchecked Sendable {
|
|
64
|
+
private let lock = NSLock()
|
|
65
|
+
private let url: URL
|
|
66
|
+
private let writeOptions: Data.WritingOptions
|
|
67
|
+
|
|
68
|
+
/// - Parameter writeOptions: added to `.atomic`; on iOS, the file protection class.
|
|
69
|
+
package init(url: URL, writeOptions: Data.WritingOptions = []) {
|
|
70
|
+
self.url = url
|
|
71
|
+
self.writeOptions = writeOptions.union(.atomic)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
package func load() throws -> Value? {
|
|
75
|
+
try lock.sync {
|
|
76
|
+
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
|
77
|
+
let data: Data
|
|
78
|
+
do {
|
|
79
|
+
data = try Data(contentsOf: url)
|
|
80
|
+
} catch {
|
|
81
|
+
throw ValueStoreUnavailable(underlying: error)
|
|
82
|
+
}
|
|
83
|
+
return try? JSONDecoder().decode(Value.self, from: data)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
package func save(_ value: Value) throws {
|
|
88
|
+
try lock.sync {
|
|
89
|
+
let data = try JSONEncoder().encode(value)
|
|
90
|
+
do {
|
|
91
|
+
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
92
|
+
try data.write(to: url, options: writeOptions)
|
|
93
|
+
} catch {
|
|
94
|
+
throw ValueStoreUnavailable(underlying: error)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
package func delete() throws {
|
|
100
|
+
try lock.sync {
|
|
101
|
+
guard FileManager.default.fileExists(atPath: url.path) else { return }
|
|
102
|
+
do {
|
|
103
|
+
try FileManager.default.removeItem(at: url)
|
|
104
|
+
} catch {
|
|
105
|
+
throw ValueStoreUnavailable(underlying: error)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#if os(iOS)
|
|
2
|
+
import Foundation
|
|
3
|
+
import UniformTypeIdentifiers
|
|
4
|
+
import UserNotifications
|
|
5
|
+
#if !COCOAPODS
|
|
6
|
+
import BubblCore
|
|
7
|
+
#endif
|
|
8
|
+
|
|
9
|
+
/// Adds the picture to Bubbl's pushes (optional). Add a Notification Service Extension target to
|
|
10
|
+
/// the app, add this package's BubblNotificationService product to it, and make the extension's
|
|
11
|
+
/// principal class a subclass:
|
|
12
|
+
///
|
|
13
|
+
/// import BubblNotificationService
|
|
14
|
+
/// class NotificationService: BubblNotificationService {}
|
|
15
|
+
///
|
|
16
|
+
/// The push's image_url is downloaded (https only, at most 10 MB, within the time iOS gives) and
|
|
17
|
+
/// attached; anything that goes wrong shows the push as it came. Other pushes pass through
|
|
18
|
+
/// untouched (and all of them below iOS 17, where Bubbl does nothing): an app with its own
|
|
19
|
+
/// extension work can override `didReceive` and call `super` only for pushes where `isBubbl(_:)`
|
|
20
|
+
/// is true.
|
|
21
|
+
///
|
|
22
|
+
/// Nothing is recorded here: the extension is a separate process with no access to Bubbl's
|
|
23
|
+
/// state, and it doesn't need any.
|
|
24
|
+
open class BubblNotificationService: UNNotificationServiceExtension, @unchecked Sendable {
|
|
25
|
+
private static let maxImageBytes = 10 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
private let lock = NSLock()
|
|
28
|
+
private var deliver: ((UNNotificationContent) -> Void)?
|
|
29
|
+
private var content: UNMutableNotificationContent?
|
|
30
|
+
private var download: URLSessionDownloadTask?
|
|
31
|
+
|
|
32
|
+
/// How long the picture may take to download. iOS gives the extension about 30 s in all.
|
|
33
|
+
open var imageTimeout: TimeInterval { 20 }
|
|
34
|
+
|
|
35
|
+
/// Whether a push is Bubbl's.
|
|
36
|
+
public static func isBubbl(_ request: UNNotificationRequest) -> Bool {
|
|
37
|
+
PushMessage.isBubbl(data(request.content.userInfo))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
open override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
|
41
|
+
// Below iOS 17 Bubbl does nothing: pushes pass through as they came.
|
|
42
|
+
guard #available(iOS 17, *) else {
|
|
43
|
+
contentHandler(request.content)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
guard let imageUrl = PushMessage.imageUrl(Self.data(request.content.userInfo)),
|
|
47
|
+
let url = URL(string: imageUrl),
|
|
48
|
+
let content = request.content.mutableCopy() as? UNMutableNotificationContent
|
|
49
|
+
else {
|
|
50
|
+
contentHandler(request.content)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
55
|
+
configuration.timeoutIntervalForResource = imageTimeout
|
|
56
|
+
let session = URLSession(configuration: configuration)
|
|
57
|
+
let task = session.downloadTask(with: url) { [weak self] location, response, _ in
|
|
58
|
+
self?.attach(location, response)
|
|
59
|
+
self?.finish()
|
|
60
|
+
}
|
|
61
|
+
lock.sync {
|
|
62
|
+
deliver = contentHandler
|
|
63
|
+
self.content = content
|
|
64
|
+
download = task
|
|
65
|
+
}
|
|
66
|
+
task.resume()
|
|
67
|
+
session.finishTasksAndInvalidate()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// iOS is about to end the extension: show the push with whatever is ready.
|
|
71
|
+
open override func serviceExtensionTimeWillExpire() {
|
|
72
|
+
lock.sync { download }?.cancel()
|
|
73
|
+
finish()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@available(iOS 17, *)
|
|
77
|
+
private func attach(_ location: URL?, _ response: URLResponse?) {
|
|
78
|
+
guard let location, let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode),
|
|
79
|
+
let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize, size > 0, size <= Self.maxImageBytes
|
|
80
|
+
else { return }
|
|
81
|
+
|
|
82
|
+
// iOS tells the picture's kind from the file's extension: from the MIME type, else the URL.
|
|
83
|
+
let fromMime = http.mimeType.flatMap { UTType(mimeType: $0)?.preferredFilenameExtension }
|
|
84
|
+
guard let fileExtension = fromMime ?? http.url.map(\.pathExtension).flatMap({ $0.isEmpty ? nil : $0 }) else { return }
|
|
85
|
+
|
|
86
|
+
let file = FileManager.default.temporaryDirectory
|
|
87
|
+
.appendingPathComponent(UUID().uuidString)
|
|
88
|
+
.appendingPathExtension(fileExtension)
|
|
89
|
+
do {
|
|
90
|
+
try FileManager.default.moveItem(at: location, to: file)
|
|
91
|
+
let attachment = try UNNotificationAttachment(identifier: "bubbl-image", url: file)
|
|
92
|
+
lock.sync { content?.attachments = [attachment] }
|
|
93
|
+
} catch {
|
|
94
|
+
// Not a picture iOS can show: the push goes as it came.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Hands iOS the content, once (the download and the time running out may both get here).
|
|
99
|
+
private func finish() {
|
|
100
|
+
let (deliver, content) = lock.sync {
|
|
101
|
+
defer { self.deliver = nil }
|
|
102
|
+
return (self.deliver, self.content)
|
|
103
|
+
}
|
|
104
|
+
if let deliver, let content { deliver(content) }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// The push's top-level data as JSON: Bubbl's keys are strings (or 1/true for the flags).
|
|
108
|
+
private static func data(_ userInfo: [AnyHashable: Any]) -> [String: JSONValue] {
|
|
109
|
+
var data: [String: JSONValue] = [:]
|
|
110
|
+
for (key, value) in userInfo {
|
|
111
|
+
guard let key = key as? String else { continue }
|
|
112
|
+
switch value {
|
|
113
|
+
case let text as String:
|
|
114
|
+
data[key] = .string(text)
|
|
115
|
+
case let number as NSNumber:
|
|
116
|
+
data[key] = CFGetTypeID(number) == CFBooleanGetTypeID() ? .bool(number.boolValue) : .int(number.int64Value)
|
|
117
|
+
default:
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return data
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
#endif
|
|
@@ -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
|
+
}
|