@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.
Files changed (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/TerraReactNative.podspec +48 -0
  4. package/android/build.gradle +87 -0
  5. package/android/src/bridgeCore/kotlin/README.md +1 -0
  6. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/BridgeBootstrap.kt +133 -0
  7. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/BridgeHost.kt +32 -0
  8. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/Conversions.kt +468 -0
  9. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/LauncherSlots.kt +41 -0
  10. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/Rejections.kt +150 -0
  11. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/StreamRegistry.kt +77 -0
  12. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridge.kt +86 -0
  13. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridgeOpsImpl.kt +258 -0
  14. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TerraBridgeStreamsImpl.kt +52 -0
  15. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/TokenBroker.kt +198 -0
  16. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeErrors.kt +217 -0
  17. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeOps.kt +493 -0
  18. package/android/src/bridgeCore/kotlin/co/tryterra/sdk/bridge/generated/BridgeTypes.kt +897 -0
  19. package/android/src/healthConnectOnly/kotlin/co/tryterra/sdk/reactnative/TerraStores.kt +11 -0
  20. package/android/src/main/AndroidManifest.xml +24 -0
  21. package/android/src/main/java/co/tryterra/sdk/reactnative/TerraBridgeModule.kt +96 -0
  22. package/android/src/main/java/co/tryterra/sdk/reactnative/TerraReactNativePackage.kt +26 -0
  23. package/android/src/main/java/co/tryterra/sdk/reactnative/TerraStartup.kt +46 -0
  24. package/android/src/samsung/kotlin/co/tryterra/sdk/reactnative/TerraStores.kt +13 -0
  25. package/docs/availability.md +246 -0
  26. package/frameworks/TerraSDK.xcframework/Info.plist +48 -0
  27. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Info.plist +0 -0
  28. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.abi.json +81513 -0
  29. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.package.swiftinterface +2150 -0
  30. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +2150 -0
  31. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  32. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios.swiftinterface +2127 -0
  33. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/PrivacyInfo.xcprivacy +96 -0
  34. package/frameworks/TerraSDK.xcframework/ios-arm64/TerraSDK.framework/TerraSDK +0 -0
  35. package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Info.plist +20 -0
  36. package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/DWARF/TerraSDK +0 -0
  37. package/frameworks/TerraSDK.xcframework/ios-arm64/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/TerraSDK.yml +6473 -0
  38. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Info.plist +0 -0
  39. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +81513 -0
  40. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.package.swiftinterface +2150 -0
  41. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +2150 -0
  42. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  43. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +2127 -0
  44. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +81513 -0
  45. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.package.swiftinterface +2150 -0
  46. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +2150 -0
  47. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  48. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/Modules/TerraSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +2127 -0
  49. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/PrivacyInfo.xcprivacy +96 -0
  50. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/TerraSDK +0 -0
  51. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/TerraSDK.framework/_CodeSignature/CodeResources +223 -0
  52. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Info.plist +20 -0
  53. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/DWARF/TerraSDK +0 -0
  54. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/TerraSDK.yml +6474 -0
  55. package/frameworks/TerraSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/TerraSDK.framework.dSYM/Contents/Resources/Relocations/x86_64/TerraSDK.yml +6392 -0
  56. package/ios/TerraBridgeCore/BridgeBootstrap.swift +89 -0
  57. package/ios/TerraBridgeCore/BridgeHost.swift +13 -0
  58. package/ios/TerraBridgeCore/Conversions.swift +467 -0
  59. package/ios/TerraBridgeCore/Generated/BridgeErrors.swift +179 -0
  60. package/ios/TerraBridgeCore/Generated/BridgeOps.swift +795 -0
  61. package/ios/TerraBridgeCore/Generated/BridgeTypes.swift +1317 -0
  62. package/ios/TerraBridgeCore/Locked.swift +15 -0
  63. package/ios/TerraBridgeCore/README.md +1 -0
  64. package/ios/TerraBridgeCore/Rejections.swift +130 -0
  65. package/ios/TerraBridgeCore/RuntimeAttachment.swift +60 -0
  66. package/ios/TerraBridgeCore/StreamRegistry.swift +59 -0
  67. package/ios/TerraBridgeCore/TerraBridge.swift +122 -0
  68. package/ios/TerraBridgeCore/TerraBridgeOpsImpl.swift +254 -0
  69. package/ios/TerraBridgeCore/TerraBridgeStreamsImpl.swift +60 -0
  70. package/ios/TerraBridgeCore/TokenBroker.swift +147 -0
  71. package/ios/TerraBridgeModule.mm +79 -0
  72. package/ios/TerraBridgeModuleImpl.swift +49 -0
  73. package/ios/TerraReactNative.swift +19 -0
  74. package/lib/EndUser.d.ts +12 -0
  75. package/lib/EndUser.js +13 -0
  76. package/lib/NativeTerraBridge.d.ts +23 -0
  77. package/lib/NativeTerraBridge.js +13 -0
  78. package/lib/Terra.d.ts +154 -0
  79. package/lib/Terra.js +280 -0
  80. package/lib/TerraError.d.ts +42 -0
  81. package/lib/TerraError.js +95 -0
  82. package/lib/createTerra.d.ts +8 -0
  83. package/lib/createTerra.js +19 -0
  84. package/lib/generated/client.d.ts +92 -0
  85. package/lib/generated/client.js +196 -0
  86. package/lib/generated/codecs.d.ts +135 -0
  87. package/lib/generated/codecs.js +1358 -0
  88. package/lib/generated/errors.d.ts +13 -0
  89. package/lib/generated/errors.js +68 -0
  90. package/lib/generated/ops.d.ts +13 -0
  91. package/lib/generated/ops.js +137 -0
  92. package/lib/generated/streams.d.ts +23 -0
  93. package/lib/generated/streams.js +58 -0
  94. package/lib/generated/types.d.ts +481 -0
  95. package/lib/generated/types.js +92 -0
  96. package/lib/index.d.ts +29 -0
  97. package/lib/index.js +55 -0
  98. package/package.json +71 -4
  99. package/src/EndUser.ts +15 -0
  100. package/src/NativeTerraBridge.ts +27 -0
  101. package/src/Terra.ts +462 -0
  102. package/src/TerraError.ts +116 -0
  103. package/src/createTerra.ts +18 -0
  104. package/src/generated/client.ts +243 -0
  105. package/src/generated/codecs.ts +1287 -0
  106. package/src/generated/errors.ts +113 -0
  107. package/src/generated/ops.ts +197 -0
  108. package/src/generated/streams.ts +74 -0
  109. package/src/generated/types.ts +667 -0
  110. 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
+ }