@tryterra/react-native 0.0.0-stage → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -2
- package/TerraReactNative.podspec +48 -0
- package/android/build.gradle +87 -0
- package/android/src/bridgeCore/kotlin/README.md +1 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/BridgeBootstrap.kt +133 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/BridgeHost.kt +32 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/Conversions.kt +468 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/LauncherSlots.kt +41 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/Rejections.kt +150 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/StreamRegistry.kt +77 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridge.kt +86 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridgeOpsImpl.kt +258 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridgeStreamsImpl.kt +52 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TokenBroker.kt +198 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeErrors.kt +217 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeOps.kt +493 -0
- package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeTypes.kt +897 -0
- package/android/src/healthConnectOnly/kotlin/co/tryterra/sdk/reactnative/TerraStores.kt +11 -0
- package/android/src/main/AndroidManifest.xml +24 -0
- package/android/src/main/java/co/tryterra/sdk/reactnative/TerraBridgeModule.kt +96 -0
- package/android/src/main/java/co/tryterra/sdk/reactnative/TerraReactNativePackage.kt +26 -0
- package/android/src/main/java/co/tryterra/sdk/reactnative/TerraStartup.kt +46 -0
- package/android/src/samsung/kotlin/co/tryterra/sdk/reactnative/TerraStores.kt +13 -0
- package/docs/availability.md +246 -0
- package/frameworks/TerraSDK.xcframework/Info.plist +48 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Info.plist +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.abi.json +81513 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.package.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.swiftinterface +2127 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/PrivacyInfo.xcprivacy +96 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/TerraSDK +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Info.plist +20 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/DWARF/TerraSDK +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/TerraSDK.yml +6473 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Info.plist +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +81513 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.package.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +2127 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +81513 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.package.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +2150 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +2127 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/PrivacyInfo.xcprivacy +96 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/TerraSDK +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/_CodeSignature/CodeResources +223 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Info.plist +20 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/DWARF/TerraSDK +0 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/TerraSDK.yml +6474 -0
- package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/x86_64/TerraSDK.yml +6392 -0
- package/ios/TerraBridgeCore/BridgeBootstrap.swift +89 -0
- package/ios/TerraBridgeCore/BridgeHost.swift +13 -0
- package/ios/TerraBridgeCore/Conversions.swift +467 -0
- package/ios/TerraBridgeCore/Generated/BridgeErrors.swift +179 -0
- package/ios/TerraBridgeCore/Generated/BridgeOps.swift +795 -0
- package/ios/TerraBridgeCore/Generated/BridgeTypes.swift +1317 -0
- package/ios/TerraBridgeCore/Locked.swift +15 -0
- package/ios/TerraBridgeCore/README.md +1 -0
- package/ios/TerraBridgeCore/Rejections.swift +130 -0
- package/ios/TerraBridgeCore/RuntimeAttachment.swift +60 -0
- package/ios/TerraBridgeCore/StreamRegistry.swift +59 -0
- package/ios/TerraBridgeCore/TerraBridge.swift +122 -0
- package/ios/TerraBridgeCore/TerraBridgeOpsImpl.swift +254 -0
- package/ios/TerraBridgeCore/TerraBridgeStreamsImpl.swift +60 -0
- package/ios/TerraBridgeCore/TokenBroker.swift +147 -0
- package/ios/TerraBridgeModule.mm +79 -0
- package/ios/TerraBridgeModuleImpl.swift +49 -0
- package/ios/TerraReactNative.swift +19 -0
- package/lib/EndUser.d.ts +12 -0
- package/lib/EndUser.js +13 -0
- package/lib/NativeTerraBridge.d.ts +23 -0
- package/lib/NativeTerraBridge.js +13 -0
- package/lib/Terra.d.ts +154 -0
- package/lib/Terra.js +280 -0
- package/lib/TerraError.d.ts +42 -0
- package/lib/TerraError.js +95 -0
- package/lib/createTerra.d.ts +8 -0
- package/lib/createTerra.js +19 -0
- package/lib/generated/client.d.ts +92 -0
- package/lib/generated/client.js +196 -0
- package/lib/generated/codecs.d.ts +135 -0
- package/lib/generated/codecs.js +1358 -0
- package/lib/generated/errors.d.ts +13 -0
- package/lib/generated/errors.js +68 -0
- package/lib/generated/ops.d.ts +13 -0
- package/lib/generated/ops.js +137 -0
- package/lib/generated/streams.d.ts +23 -0
- package/lib/generated/streams.js +58 -0
- package/lib/generated/types.d.ts +481 -0
- package/lib/generated/types.js +92 -0
- package/lib/index.d.ts +29 -0
- package/lib/index.js +55 -0
- package/package.json +71 -4
- package/src/EndUser.ts +15 -0
- package/src/NativeTerraBridge.ts +27 -0
- package/src/Terra.ts +462 -0
- package/src/TerraError.ts +116 -0
- package/src/createTerra.ts +18 -0
- package/src/generated/client.ts +243 -0
- package/src/generated/codecs.ts +1287 -0
- package/src/generated/errors.ts +113 -0
- package/src/generated/ops.ts +197 -0
- package/src/generated/streams.ts +74 -0
- package/src/generated/types.ts +667 -0
- package/src/index.ts +52 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// State behind a lock. `Mutex` needs iOS 18, and the floor is 15.
|
|
4
|
+
final class Locked<State>: @unchecked Sendable {
|
|
5
|
+
private let lock = NSLock()
|
|
6
|
+
private var state: State
|
|
7
|
+
|
|
8
|
+
init(_ state: State) { self.state = state }
|
|
9
|
+
|
|
10
|
+
func withLock<Result>(_ body: (inout State) throws -> Result) rethrows -> Result {
|
|
11
|
+
lock.lock()
|
|
12
|
+
defer { lock.unlock() }
|
|
13
|
+
return try body(&state)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Copied from hybrid-common by scripts/vendor-bridge-core.js. Do not edit; change hybrid-common.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import TerraSDK
|
|
3
|
+
|
|
4
|
+
/// A thrown error as a value another language can switch on.
|
|
5
|
+
///
|
|
6
|
+
/// The code and retryability come from the SDK (`TerraError.code`, the vocabulary in
|
|
7
|
+
/// `docs/sdk-reference.md`), so there is no table here to drift. What this owns is which parts of
|
|
8
|
+
/// an error are safe to send: nothing in `message` or `details` may carry a health value or text
|
|
9
|
+
/// written by code this SDK does not own.
|
|
10
|
+
enum Rejections {
|
|
11
|
+
|
|
12
|
+
static func payload(for error: any Error) -> TerraErrorPayload {
|
|
13
|
+
switch error {
|
|
14
|
+
case let error as TerraError:
|
|
15
|
+
// The broker's own failures reach the SDK through the token provider, which wraps
|
|
16
|
+
// them. Unwrapped here so `bridge.token_timeout` stays distinct from a provider that
|
|
17
|
+
// answered with an error.
|
|
18
|
+
if case .tokenProviderFailed(let underlying) = error, let bridge = underlying as? BridgeError {
|
|
19
|
+
return payload(for: bridge)
|
|
20
|
+
}
|
|
21
|
+
return TerraErrorPayload(
|
|
22
|
+
code: error.code,
|
|
23
|
+
retryable: error.isRetryable,
|
|
24
|
+
message: message(error),
|
|
25
|
+
source: source(error),
|
|
26
|
+
details: details(error))
|
|
27
|
+
case let error as BridgeError:
|
|
28
|
+
return TerraErrorPayload(
|
|
29
|
+
code: error.code,
|
|
30
|
+
retryable: error.isRetryable,
|
|
31
|
+
message: message(error),
|
|
32
|
+
details: details(error))
|
|
33
|
+
case is CancellationError:
|
|
34
|
+
return platform("the call was cancelled")
|
|
35
|
+
default:
|
|
36
|
+
return platform("a platform call failed")
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private static func platform(_ message: String) -> TerraErrorPayload {
|
|
41
|
+
TerraErrorPayload(code: "platform", retryable: false, message: message)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Two cases wrap an error this SDK did not raise, and its text is as likely to carry a token
|
|
45
|
+
/// or a reading as to describe the failure, so neither crosses.
|
|
46
|
+
private static func message(_ error: TerraError) -> String {
|
|
47
|
+
switch error {
|
|
48
|
+
case .tokenProviderFailed: return "the host's token provider failed"
|
|
49
|
+
case .platform: return "a platform call failed"
|
|
50
|
+
// The SDK's description embeds its cause's, which can be either of the two above.
|
|
51
|
+
case .rollbackIncomplete(let remaining, let cause):
|
|
52
|
+
return "the log failed (\(message(cause))) and \(remaining.count) sample(s) could not be removed"
|
|
53
|
+
default: return error.errorDescription ?? error.code
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private static func message(_ error: BridgeError) -> String {
|
|
58
|
+
switch error {
|
|
59
|
+
case .notBootstrapped: return "the native bootstrap did not run"
|
|
60
|
+
case .unsupportedOnPlatform(let op): return "\(op) is not available on iOS"
|
|
61
|
+
case .tokenTimeout(let timeoutMs): return "the token provider did not answer within \(timeoutMs) ms"
|
|
62
|
+
case .invalidPayload(let path): return "the payload is invalid at \(path)"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private static func source(_ error: TerraError) -> Source? {
|
|
67
|
+
switch error {
|
|
68
|
+
case .notConnected(let source), .linkRevoked(let source), .devIDMismatch(let source),
|
|
69
|
+
.linkAlreadyRunning(let source):
|
|
70
|
+
return Conversions.source(source)
|
|
71
|
+
default:
|
|
72
|
+
return nil
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private static func details(_ error: TerraError) -> BridgeJSON? {
|
|
77
|
+
switch error {
|
|
78
|
+
case .routeAwaitingConverter(let type): return bag(["dataType": type.rawValue])
|
|
79
|
+
case .syncAlreadyRunning(let type): return bag(["dataType": type.rawValue])
|
|
80
|
+
case .permissionDenied(let category): return bag(["category": category.rawValue])
|
|
81
|
+
case .sampleNotFound(let id): return bag(["id": id.uuidString])
|
|
82
|
+
// The cause travels as its code: a nested error's description carries its own detail.
|
|
83
|
+
case .rollbackIncomplete(let remaining, let cause):
|
|
84
|
+
return BridgeJSON(["remaining": .number(Double(remaining.count)), "cause": .string(cause.code)])
|
|
85
|
+
case .distanceNotAttributable(let activity):
|
|
86
|
+
return bag(["activity": Conversions.sortKey(Conversions.activity(activity))])
|
|
87
|
+
case .network(let failure): return details(failure)
|
|
88
|
+
case .tokenProviderFailed, .platform:
|
|
89
|
+
return nil
|
|
90
|
+
case .healthDataUnavailable, .notConnected, .tokenUnavailable, .tokenRejected,
|
|
91
|
+
.userLimitReached, .upgradeRequired, .deviceKeyUnavailable, .signatureRejected,
|
|
92
|
+
.clockSkewed, .linkRevoked, .devIDMismatch, .linkConflict, .linkAlreadyRunning,
|
|
93
|
+
.storeLocked, .storeUnreadable, .deadlineExceeded, .authorizationNotDetermined,
|
|
94
|
+
.bloodPressureIncomplete, .plannedWorkoutUnnamed, .authorizationUnavailableInExtension:
|
|
95
|
+
return nil
|
|
96
|
+
@unknown default:
|
|
97
|
+
return nil
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private static func details(_ failure: TerraError.Network) -> BridgeJSON? {
|
|
102
|
+
switch failure {
|
|
103
|
+
case .serverError(let status): return number("status", status)
|
|
104
|
+
case .throttled(let retryAfter):
|
|
105
|
+
return retryAfter.map { number("retryAfterSeconds", Int($0.rounded(.up))) }
|
|
106
|
+
// A URLError code says nothing a caller can act on beyond the code itself.
|
|
107
|
+
case .transport, .offline, .timedOut, .malformedResponse:
|
|
108
|
+
return nil
|
|
109
|
+
@unknown default:
|
|
110
|
+
return nil
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private static func details(_ error: BridgeError) -> BridgeJSON? {
|
|
115
|
+
switch error {
|
|
116
|
+
case .notBootstrapped(let line): return bag(["line": line])
|
|
117
|
+
case .unsupportedOnPlatform(let op): return bag(["op": op])
|
|
118
|
+
case .tokenTimeout(let timeoutMs): return number("timeoutMs", timeoutMs)
|
|
119
|
+
case .invalidPayload(let path): return bag(["path": path])
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private static func bag(_ values: [String: String]) -> BridgeJSON {
|
|
124
|
+
BridgeJSON(values.mapValues { .string($0) })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private static func number(_ key: String, _ value: Int) -> BridgeJSON {
|
|
128
|
+
BridgeJSON([key: .number(Double(value))])
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public final class RuntimeAttachment: Sendable {
|
|
4
|
+
private struct State {
|
|
5
|
+
var attached: BridgeSession?
|
|
6
|
+
var invalidated = false
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
private let state = Locked(State())
|
|
10
|
+
private let attach: @Sendable () throws(BridgeRejection) -> BridgeSession
|
|
11
|
+
|
|
12
|
+
/// Attaches to what `BridgeBootstrap` parked. `missingLine` is what `bridge.not_bootstrapped` names.
|
|
13
|
+
public convenience init(
|
|
14
|
+
missingLine: String,
|
|
15
|
+
emit: @escaping @Sendable (_ subscriptionId: String, _ message: String) -> Void
|
|
16
|
+
) {
|
|
17
|
+
self.init { () throws(BridgeRejection) -> BridgeSession in
|
|
18
|
+
try BridgeBootstrap.attach(missingLine: missingLine, emit: emit)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
init(attach: @escaping @Sendable () throws(BridgeRejection) -> BridgeSession) {
|
|
23
|
+
self.attach = attach
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public func session() throws(BridgeRejection) -> BridgeSession? {
|
|
27
|
+
let attach = attach
|
|
28
|
+
let outcome: Result<BridgeSession?, BridgeRejection> = state.withLock { state in
|
|
29
|
+
if state.invalidated { return .success(nil) }
|
|
30
|
+
if let attached = state.attached { return .success(attached) }
|
|
31
|
+
do throws(BridgeRejection) {
|
|
32
|
+
let session = try attach()
|
|
33
|
+
state.attached = session
|
|
34
|
+
return .success(session)
|
|
35
|
+
} catch {
|
|
36
|
+
return .failure(error)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return try outcome.get()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// The attached session, without attaching one.
|
|
43
|
+
public func current() -> BridgeSession? {
|
|
44
|
+
state.withLock { $0.attached }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public var isInvalidated: Bool {
|
|
48
|
+
state.withLock { $0.invalidated }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@discardableResult
|
|
52
|
+
public func invalidate() -> Task<Void, Never> {
|
|
53
|
+
let session = state.withLock { state -> BridgeSession? in
|
|
54
|
+
state.invalidated = true
|
|
55
|
+
defer { state.attached = nil }
|
|
56
|
+
return state.attached
|
|
57
|
+
}
|
|
58
|
+
return Task { await session?.invalidate() }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// One native stream per subscriber, started on subscribe and cancelled on unsubscribe.
|
|
4
|
+
///
|
|
5
|
+
/// What arrives is already framed, endings included, by the generated `BridgeStreamDispatch`.
|
|
6
|
+
final class StreamRegistry: Sendable {
|
|
7
|
+
typealias Sink = @Sendable (_ subscriptionId: String, _ message: String) -> Void
|
|
8
|
+
|
|
9
|
+
private struct State {
|
|
10
|
+
var next = 0
|
|
11
|
+
var tasks: [String: Task<Void, Never>] = [:]
|
|
12
|
+
var closed = false
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
private let state = Locked(State())
|
|
16
|
+
|
|
17
|
+
/// Forwards `source`, already framed with its ending by `BridgeStreamDispatch`, until it ends
|
|
18
|
+
/// or is cancelled, and answers the id that cancels it.
|
|
19
|
+
///
|
|
20
|
+
/// The task is created under the lock, so its own removal at the end cannot run before its
|
|
21
|
+
/// insertion - a source that finishes at once would otherwise leave its entry behind forever.
|
|
22
|
+
func start(sink: @escaping Sink, source: AsyncStream<Data>) -> String {
|
|
23
|
+
state.withLock { state in
|
|
24
|
+
state.next += 1
|
|
25
|
+
let id = "sub-\(state.next)"
|
|
26
|
+
guard !state.closed else { return id }
|
|
27
|
+
state.tasks[id] = Task {
|
|
28
|
+
for await message in source {
|
|
29
|
+
// A cancelled source still frames an ending; the subscriber asked for silence.
|
|
30
|
+
guard !Task.isCancelled else { break }
|
|
31
|
+
sink(id, String(decoding: message, as: UTF8.self))
|
|
32
|
+
}
|
|
33
|
+
self.finished(id)
|
|
34
|
+
}
|
|
35
|
+
return id
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// An unknown id is ignored: a second unsubscribe is not an error.
|
|
40
|
+
func cancel(_ id: String) {
|
|
41
|
+
state.withLock { $0.tasks.removeValue(forKey: id) }?.cancel()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Cancels everything and starts nothing new, for a host runtime that is going away.
|
|
45
|
+
func close() {
|
|
46
|
+
let tasks = state.withLock { state in
|
|
47
|
+
state.closed = true
|
|
48
|
+
defer { state.tasks = [:] }
|
|
49
|
+
return Array(state.tasks.values)
|
|
50
|
+
}
|
|
51
|
+
for task in tasks { task.cancel() }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
var count: Int { state.withLock { $0.tasks.count } }
|
|
55
|
+
|
|
56
|
+
private func finished(_ id: String) {
|
|
57
|
+
state.withLock { _ = $0.tasks.removeValue(forKey: id) }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import TerraSDK
|
|
3
|
+
|
|
4
|
+
/// The process-wide half: the client, and the broker it was built with.
|
|
5
|
+
///
|
|
6
|
+
/// Built once, at launch, by the native bootstrap - before any JavaScript or Dart exists, because
|
|
7
|
+
/// background sync needs a live client then. A runtime that comes and goes (a JS reload, a Flutter
|
|
8
|
+
/// engine) attaches a `BridgeSession` to it and detaches by invalidating that session.
|
|
9
|
+
///
|
|
10
|
+
/// Nothing here may mention a framework: no promise type, no `NSDictionary`, no `FlutterResult`.
|
|
11
|
+
public final class TerraBridge: Sendable {
|
|
12
|
+
public typealias TokenProvider = @Sendable () async throws -> String
|
|
13
|
+
|
|
14
|
+
/// The client, for the bootstrap that registers it with `BackgroundSync`.
|
|
15
|
+
public let terra: Terra
|
|
16
|
+
|
|
17
|
+
let host: any BridgeHost
|
|
18
|
+
let broker: TokenBroker
|
|
19
|
+
let deviceZone: @Sendable () -> TimeZone
|
|
20
|
+
|
|
21
|
+
/// `client` receives the provider the client must be built with. The broker exists before the
|
|
22
|
+
/// client because the client captures its provider at construction: a broker made afterwards
|
|
23
|
+
/// would be one the SDK never asks.
|
|
24
|
+
public init(
|
|
25
|
+
host: any BridgeHost,
|
|
26
|
+
deviceZone: @escaping @Sendable () -> TimeZone = { .current },
|
|
27
|
+
client: (_ tokenProvider: @escaping TokenProvider) throws -> Terra
|
|
28
|
+
) rethrows {
|
|
29
|
+
let broker = TokenBroker()
|
|
30
|
+
self.broker = broker
|
|
31
|
+
self.host = host
|
|
32
|
+
self.deviceZone = deviceZone
|
|
33
|
+
self.terra = try client { try await broker.provide() }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// One session per runtime. `emit` receives `(subscriptionId, StreamMessage JSON)`.
|
|
37
|
+
public func attach(emit: @escaping @Sendable (_ subscriptionId: String, _ message: String) -> Void) -> BridgeSession {
|
|
38
|
+
BridgeSession(bridge: self, emit: emit)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// What a framework layer calls: dispatch, subscribe, unsubscribe, invalidate.
|
|
43
|
+
///
|
|
44
|
+
/// A React Native TurboModule forwards `invoke` to `dispatch` and its emitter to `emit`. A Flutter
|
|
45
|
+
/// `MethodChannel` is `dispatch(call.method, call.arguments)` and an `EventChannel` wraps
|
|
46
|
+
/// `subscribe`.
|
|
47
|
+
public final class BridgeSession: Sendable {
|
|
48
|
+
private let bridge: TerraBridge
|
|
49
|
+
private let emit: @Sendable (String, String) -> Void
|
|
50
|
+
private let subscriptions = StreamRegistry()
|
|
51
|
+
private let audience = TokenAudience()
|
|
52
|
+
private let ops: BridgeDispatch
|
|
53
|
+
private let streams: BridgeStreamDispatch
|
|
54
|
+
|
|
55
|
+
init(bridge: TerraBridge, emit: @escaping @Sendable (String, String) -> Void) {
|
|
56
|
+
self.bridge = bridge
|
|
57
|
+
self.emit = emit
|
|
58
|
+
self.ops = BridgeDispatch(ops: TerraBridgeOpsImpl(
|
|
59
|
+
terra: bridge.terra, host: bridge.host, broker: bridge.broker, audience: audience))
|
|
60
|
+
self.streams = BridgeStreamDispatch(
|
|
61
|
+
streams: TerraBridgeStreamsImpl(terra: bridge.terra, audience: audience, deviceZone: bridge.deviceZone),
|
|
62
|
+
errorPayload: { Rejections.payload(for: $0) })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// A session dropped without `invalidate` still stops its streams: they would otherwise keep
|
|
66
|
+
/// a source polling for a runtime that is gone.
|
|
67
|
+
deinit {
|
|
68
|
+
audience.close()
|
|
69
|
+
subscriptions.close()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Runs one op and answers its response JSON. Never throws a raw SDK error: the framework layer
|
|
73
|
+
/// rejects with `rejection.payload`, so the error table lives here once rather than in each
|
|
74
|
+
/// wrapper.
|
|
75
|
+
public func dispatch(op: String, payload: String) async throws(BridgeRejection) -> String {
|
|
76
|
+
do {
|
|
77
|
+
let response = try await ops(op: op, payload: Data(payload.utf8))
|
|
78
|
+
return String(decoding: response, as: UTF8.self)
|
|
79
|
+
} catch {
|
|
80
|
+
throw BridgeRejection(payload: Rejections.payload(for: error))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Starts one native stream for this subscriber and answers the id that cancels it. Messages
|
|
85
|
+
/// arrive through `emit` under that id. The source is open before this returns.
|
|
86
|
+
public func subscribe(stream: String, params: String) async throws(BridgeRejection) -> String {
|
|
87
|
+
do {
|
|
88
|
+
let source = try await streams.open(stream: stream, params: Data(params.utf8))
|
|
89
|
+
return subscriptions.start(sink: emit, source: source)
|
|
90
|
+
} catch {
|
|
91
|
+
throw BridgeRejection(payload: Rejections.payload(for: error))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public func unsubscribe(id: String) {
|
|
96
|
+
subscriptions.cancel(id)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// For a runtime that is going away. The client outlives it, so what is torn down is everything
|
|
100
|
+
/// that pointed at this runtime: its streams, and the token asks it was sent.
|
|
101
|
+
public func invalidate() async {
|
|
102
|
+
audience.close()
|
|
103
|
+
subscriptions.close()
|
|
104
|
+
await bridge.broker.invalidate(audience)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// Visible for tests.
|
|
108
|
+
var liveSubscriptions: Int { subscriptions.count }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// What the framework layer turns into a rejected promise.
|
|
112
|
+
public struct BridgeRejection: Error, Sendable {
|
|
113
|
+
public let payload: TerraErrorPayload
|
|
114
|
+
|
|
115
|
+
public var code: String { payload.code }
|
|
116
|
+
|
|
117
|
+
/// The payload as the wire spells it, for a layer that rejects with a JSON string.
|
|
118
|
+
public var json: String {
|
|
119
|
+
(try? String(decoding: BridgeDispatch.makeEncoder().encode(payload), as: UTF8.self))
|
|
120
|
+
?? #"{"code":"\#(payload.code)","message":"","retryable":\#(payload.retryable)}"#
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
@_spi(Bridge) import TerraSDK
|
|
3
|
+
|
|
4
|
+
/// The hand-written half: one short function per op.
|
|
5
|
+
///
|
|
6
|
+
/// It cannot be incomplete, because `TerraBridgeOps` is generated from the schema and an op added
|
|
7
|
+
/// there stops this compiling. Conversions live in `Conversions`; what is left is the shape of each
|
|
8
|
+
/// call, and the one place Apple's surface differs from the wire's: `sync`, `disconnect` and plans
|
|
9
|
+
/// hang off a `Connection` here rather than off the client.
|
|
10
|
+
struct TerraBridgeOpsImpl: TerraBridgeOps {
|
|
11
|
+
let terra: Terra
|
|
12
|
+
let host: any BridgeHost
|
|
13
|
+
let broker: TokenBroker
|
|
14
|
+
let audience: TokenAudience
|
|
15
|
+
|
|
16
|
+
// MARK: client
|
|
17
|
+
|
|
18
|
+
func clientAttach(_ request: Empty) async throws -> BootstrapState {
|
|
19
|
+
BootstrapState(
|
|
20
|
+
bootstrapped: true,
|
|
21
|
+
devId: host.devID,
|
|
22
|
+
platform: "ios",
|
|
23
|
+
sdkVersion: TerraSDK.sdkVersion,
|
|
24
|
+
backgroundRegistered: await host.backgroundRegistered())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func clientConnect(_ request: ConnectRequest) async throws -> Connection {
|
|
28
|
+
let source = try Conversions.source(request.source)
|
|
29
|
+
let endUser = Conversions.endUser(request.endUser)
|
|
30
|
+
if let token = request.token {
|
|
31
|
+
return Conversions.connection(try await terra.connect(source, for: endUser, using: token))
|
|
32
|
+
}
|
|
33
|
+
// The client's provider is the broker, so it is never nil, and the SDK's answer for a
|
|
34
|
+
// revoked link with no provider - `linkRevoked` - would become the broker's "nobody is
|
|
35
|
+
// listening". So the bridge answers it here, before any link starts.
|
|
36
|
+
if await !broker.canAnswer, await terra.needsTokenForRevokedLink(source, for: endUser) {
|
|
37
|
+
throw TerraError.linkRevoked(source)
|
|
38
|
+
}
|
|
39
|
+
return Conversions.connection(try await terra.connect(source, for: endUser))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func clientConnections(_ request: Empty) async throws -> ConnectionList {
|
|
43
|
+
ConnectionList(connections: try await terra.connections.map(Conversions.connection))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func clientDisconnect(_ request: SourceRequest) async throws -> Empty {
|
|
47
|
+
try await connection(request.source).disconnect()
|
|
48
|
+
return Empty()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// MARK: token
|
|
52
|
+
|
|
53
|
+
func tokenSetProviderPresent(_ request: TokenProviderRequest) async throws -> Empty {
|
|
54
|
+
await broker.setProviderPresent(request.present, timeoutMs: request.timeoutMs, audience: audience)
|
|
55
|
+
return Empty()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func tokenResolve(_ request: TokenResolveRequest) async throws -> Empty {
|
|
59
|
+
await broker.resolve(request.requestId, token: request.token)
|
|
60
|
+
return Empty()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func tokenReject(_ request: TokenRejectRequest) async throws -> Empty {
|
|
64
|
+
await broker.reject(request.requestId, message: request.message)
|
|
65
|
+
return Empty()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// MARK: permissions
|
|
69
|
+
|
|
70
|
+
/// HealthKit will not say what was granted for reads, so Apple answers nothing.
|
|
71
|
+
func permissionsRequest(_ request: PermissionRequest) async throws -> PermissionGrant {
|
|
72
|
+
_ = try Conversions.source(request.source)
|
|
73
|
+
try await terra.permissions.request(
|
|
74
|
+
read: request.read.map(Conversions.category),
|
|
75
|
+
write: request.write.map(Conversions.category),
|
|
76
|
+
workouts: request.workouts.map(Conversions.scope))
|
|
77
|
+
return PermissionGrant()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func permissionsStatus(_ request: CategoryRequest) async throws -> StatusResult {
|
|
81
|
+
_ = try Conversions.source(request.source)
|
|
82
|
+
return StatusResult(status: Conversions.permissionStatus(
|
|
83
|
+
await terra.permissions.status(for: Conversions.category(request.category))))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func permissionsWriteStatus(_ request: CategoryRequest) async throws -> StatusResult {
|
|
87
|
+
_ = try Conversions.source(request.source)
|
|
88
|
+
return StatusResult(status: Conversions.permissionStatus(
|
|
89
|
+
await terra.permissions.writeStatus(for: Conversions.category(request.category))))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func permissionsRequested(_ request: SourceRequest) async throws -> HealthAccess {
|
|
93
|
+
_ = try Conversions.source(request.source)
|
|
94
|
+
return Conversions.access(await terra.permissions.requested)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// MARK: diagnostics
|
|
98
|
+
|
|
99
|
+
func diagnosticsState(_ request: SourceRequest) async throws -> DiagnosticsState {
|
|
100
|
+
Conversions.diagnosticsState(await terra.diagnostics.state(for: try Conversions.source(request.source)))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func diagnosticsLastDelivery(_ request: SourceRequest) async throws -> LastDeliveryResult {
|
|
104
|
+
let at = await terra.diagnostics.lastDelivery(for: try Conversions.source(request.source))
|
|
105
|
+
return LastDeliveryResult(at: at.map(BridgeInstant.format))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func diagnosticsReport(_ request: SourceRequest) async throws -> SupportReport {
|
|
109
|
+
try Conversions.report(await terra.diagnostics.report(for: try Conversions.source(request.source)))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func diagnosticsSend(_ request: SourceRequest) async throws -> Empty {
|
|
113
|
+
try await terra.diagnostics.send(for: try Conversions.source(request.source))
|
|
114
|
+
return Empty()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// MARK: background
|
|
118
|
+
|
|
119
|
+
func backgroundIsRegistered(_ request: Empty) async throws -> BoolResult {
|
|
120
|
+
BoolResult(value: await host.backgroundRegistered())
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// MARK: body
|
|
124
|
+
|
|
125
|
+
func bodyHeartRate(_ request: RangeRequest) async throws -> HeartRateList {
|
|
126
|
+
HeartRateList(samples: try await terra.body.heartRate(in: Conversions.timeRange(request.range, at: "$.range"))
|
|
127
|
+
.map(Conversions.heartRate))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func bodyLogHeartRate(_ request: HeartRateEntryList) async throws -> RecordIdList {
|
|
131
|
+
ids(try await terra.body.log(entries(request.entries, Conversions.entry)))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func bodyLog(_ request: BodyEntryList) async throws -> RecordIdList {
|
|
135
|
+
ids(try await terra.body.log(entries(request.entries, Conversions.entry)))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func bodyDelete(_ request: RecordIdRequest) async throws -> Empty {
|
|
139
|
+
try await terra.body.delete(id: Conversions.uuid(request.id, at: "$.id"))
|
|
140
|
+
return Empty()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// MARK: daily
|
|
144
|
+
|
|
145
|
+
func dailySteps(_ request: RangeRequest) async throws -> StepCountList {
|
|
146
|
+
StepCountList(samples: try await terra.daily.steps(in: Conversions.timeRange(request.range, at: "$.range"))
|
|
147
|
+
.map(Conversions.steps))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func dailyLog(_ request: StepEntryList) async throws -> RecordIdList {
|
|
151
|
+
ids(try await terra.daily.log(entries(request.entries, Conversions.entry)))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func dailyDelete(_ request: RecordIdRequest) async throws -> Empty {
|
|
155
|
+
try await terra.daily.delete(id: Conversions.uuid(request.id, at: "$.id"))
|
|
156
|
+
return Empty()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// MARK: sleep
|
|
160
|
+
|
|
161
|
+
func sleepSegments(_ request: RangeRequest) async throws -> SleepSegmentList {
|
|
162
|
+
SleepSegmentList(samples: try await terra.sleep.segments(in: Conversions.timeRange(request.range, at: "$.range"))
|
|
163
|
+
.map(Conversions.segment))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func sleepLog(_ request: SleepEntryList) async throws -> RecordIdList {
|
|
167
|
+
ids(try await terra.sleep.log(entries(request.entries, Conversions.entry)))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func sleepDelete(_ request: RecordIdRequest) async throws -> Empty {
|
|
171
|
+
try await terra.sleep.delete(id: Conversions.uuid(request.id, at: "$.id"))
|
|
172
|
+
return Empty()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// MARK: activity
|
|
176
|
+
|
|
177
|
+
func activityWorkouts(_ request: RangeRequest) async throws -> WorkoutList {
|
|
178
|
+
WorkoutList(samples: try await terra.activity.workouts(in: Conversions.timeRange(request.range, at: "$.range"))
|
|
179
|
+
.map(Conversions.workout))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func activityLog(_ request: WorkoutEntryList) async throws -> RecordIdList {
|
|
183
|
+
ids(try await terra.activity.log(entries(request.entries, Conversions.entry)))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func activityDelete(_ request: RecordIdRequest) async throws -> Empty {
|
|
187
|
+
try await terra.activity.delete(id: Conversions.uuid(request.id, at: "$.id"))
|
|
188
|
+
return Empty()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// MARK: nutrition
|
|
192
|
+
|
|
193
|
+
func nutritionLog(_ request: NutritionEntryList) async throws -> RecordIdList {
|
|
194
|
+
ids(try await terra.nutrition.log(entries(request.entries, Conversions.entry)))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
func nutritionDelete(_ request: RecordIdRequest) async throws -> Empty {
|
|
198
|
+
try await terra.nutrition.delete(id: Conversions.uuid(request.id, at: "$.id"))
|
|
199
|
+
return Empty()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// MARK: planned workouts
|
|
203
|
+
|
|
204
|
+
func plannedWorkoutsPlanned(_ request: Empty) async throws -> PlannedWorkoutList {
|
|
205
|
+
PlannedWorkoutList(workouts: try await connection(nil).workouts.planned().map(Conversions.plannedWorkout))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func plannedWorkoutsMarkComplete(_ request: MarkCompleteRequest) async throws -> Empty {
|
|
209
|
+
let plans = try await connection(nil).workouts
|
|
210
|
+
if let at = request.at {
|
|
211
|
+
try await plans.markComplete(id: request.id, at: Conversions.instant(at, at: "$.at"))
|
|
212
|
+
} else {
|
|
213
|
+
try await plans.markComplete(id: request.id)
|
|
214
|
+
}
|
|
215
|
+
return Empty()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func plannedWorkoutsDelete(_ request: RecordIdRequest) async throws -> Empty {
|
|
219
|
+
try await connection(nil).workouts.delete(id: request.id)
|
|
220
|
+
return Empty()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
func plannedWorkoutsSchedulingStatus(_ request: Empty) async throws -> WorkoutScheduling {
|
|
224
|
+
WorkoutScheduling(status: Conversions.schedulingStatus(
|
|
225
|
+
try await connection(nil).workouts.schedulingStatus()))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func plannedWorkoutsRequestScheduling(_ request: Empty) async throws -> WorkoutScheduling {
|
|
229
|
+
WorkoutScheduling(status: Conversions.schedulingStatus(
|
|
230
|
+
try await connection(nil).workouts.requestScheduling()))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// MARK: -
|
|
234
|
+
|
|
235
|
+
/// Apple hangs per-link behaviour on the `Connection`, so an op naming a source finds it first.
|
|
236
|
+
private func connection(_ wire: Source?) async throws -> TerraSDK.Connection {
|
|
237
|
+
let source = try Conversions.source(wire)
|
|
238
|
+
guard let connection = try await terra.connections.first(where: { $0.source == source }) else {
|
|
239
|
+
throw TerraError.notConnected(source)
|
|
240
|
+
}
|
|
241
|
+
return connection
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// Each entry converted with its own path, so a bad one is named by index.
|
|
245
|
+
private func entries<Wire, Entry>(
|
|
246
|
+
_ wire: [Wire], _ convert: (Wire, String) throws -> Entry
|
|
247
|
+
) throws -> [Entry] {
|
|
248
|
+
try wire.enumerated().map { try convert($1, "$.entries[\($0)]") }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private func ids(_ ids: [UUID]) -> RecordIdList {
|
|
252
|
+
RecordIdList(ids: ids.map(\.uuidString))
|
|
253
|
+
}
|
|
254
|
+
}
|