@independo/capacitor-inderun 1.0.1-dev.1 → 1.1.0-dev.10

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 (33) hide show
  1. package/Package.swift +19 -2
  2. package/README.md +190 -13
  3. package/android/build.gradle.kts +12 -6
  4. package/android/settings.gradle +7 -0
  5. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunCapacitorPlugin.kt +154 -0
  6. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunSerializer.kt +203 -1
  7. package/android/src/main/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistry.kt +118 -0
  8. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerCapabilitiesTest.kt +253 -0
  9. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerStreamTest.kt +226 -0
  10. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunSerializerTest.kt +79 -0
  11. package/android/src/test/kotlin/app/independo/inderun/capacitor/IndeRunStreamRegistryTest.kt +163 -0
  12. package/dist/definitions.d.ts +175 -1
  13. package/dist/definitions.d.ts.map +1 -1
  14. package/dist/errors.d.ts +19 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/dist/errors.js +36 -0
  17. package/dist/index.d.ts +17 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +44 -13
  20. package/dist/streaming.d.ts +17 -0
  21. package/dist/streaming.d.ts.map +1 -0
  22. package/dist/streaming.js +303 -0
  23. package/dist/web.d.ts +18 -2
  24. package/dist/web.d.ts.map +1 -1
  25. package/dist/web.js +136 -7
  26. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorBridge.swift +181 -0
  27. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorPlugin.swift +93 -3
  28. package/ios/Sources/IndeRunCapacitorPlugin/IndeRunCapacitorStreamRegistry.swift +121 -0
  29. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorBridgeTests.swift +234 -0
  30. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamCodecTests.swift +178 -0
  31. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamPumpTests.swift +215 -0
  32. package/ios/Tests/IndeRunCapacitorTests/IndeRunCapacitorStreamRegistryTests.swift +154 -0
  33. package/package.json +21 -9
package/dist/web.js CHANGED
@@ -1,15 +1,36 @@
1
1
  import { WebPlugin } from "@capacitor/core";
2
2
  import { createIndeRunWeb, createUnavailable, toIndeRunException } from "@independo/inderun-web";
3
+ import { STREAM_ERROR_NAME, STREAM_EVENT_NAME } from "./streaming.js";
3
4
  export class IndeRunWeb extends WebPlugin {
4
5
  engine = null;
6
+ // Deliberately the bridge's StreamRun, not the web SDK's: the engine's run is
7
+ // assigned straight into this map, so the two shapes staying identical is checked
8
+ // by the compiler rather than by convention.
9
+ activeStreams = new Map();
10
+ startingStreams = new Set();
11
+ pendingCancels = new Map();
5
12
  async configure(options) {
6
- if (!options?.openAI) {
7
- throw createUnavailable("Capacitor web execution requires OpenAI provider registration. Configure with openAI bootstrap options before calling run(request).").toContractError();
13
+ // The web SDK requires at least one provider, not specifically the cloud one. Held
14
+ // here as well as there so the failure is an `Unavailable` contract error rather than
15
+ // a raw SDK throw, which is what the facade's callers expect.
16
+ if (!options?.openAI && !options?.systemModel && !options?.onnx) {
17
+ throw createUnavailable("Capacitor web execution requires at least one provider registration. Configure with openAI, systemModel and/or onnx bootstrap options before calling run(request).").toContractError();
8
18
  }
9
19
  try {
10
- const webOptions = {
11
- openAI: compactOpenAIOptions(options)
12
- };
20
+ // The SDK's own option type, not a hand-written copy of it: an upstream rename
21
+ // then fails at the type level instead of silently dropping a field. This is
22
+ // deliberately the *outbound* shape only — `ConfigureOptions` stays a narrower,
23
+ // JSON-serializable subset, because it has to cross the native bridge hop.
24
+ const webOptions = {};
25
+ if (options.openAI !== undefined) {
26
+ webOptions.openAI = compactOpenAIOptions(options.openAI);
27
+ }
28
+ if (options.systemModel !== undefined) {
29
+ webOptions.systemModel = compactSystemModelOptions(options.systemModel);
30
+ }
31
+ if (options.onnx !== undefined) {
32
+ webOptions.onnx = compactOnnxOptions(options.onnx);
33
+ }
13
34
  if (options.allowDirectOpenAIEndpoint !== undefined) {
14
35
  webOptions.allowDirectOpenAIEndpoint = options.allowDirectOpenAIEndpoint;
15
36
  }
@@ -30,9 +51,95 @@ export class IndeRunWeb extends WebPlugin {
30
51
  throw toIndeRunException(error).toContractError();
31
52
  }
32
53
  }
54
+ async checkCapabilities() {
55
+ if (!this.engine) {
56
+ throw createUnavailable("Capacitor IndeRun has not been configured. Configure providers before calling checkCapabilities().").toContractError();
57
+ }
58
+ try {
59
+ // Deliberately uncast: the engine's snapshots are assigned straight into the
60
+ // bridge's own `ProviderCapabilitySnapshot`, so the two declarations staying
61
+ // identical is a compile error rather than a convention. See definitions.ts.
62
+ return { providers: await this.engine.checkCapabilities() };
63
+ }
64
+ catch (error) {
65
+ throw toIndeRunException(error).toContractError();
66
+ }
67
+ }
68
+ /**
69
+ * The web path deliberately round-trips through `notifyListeners` rather than
70
+ * handing the engine's `StreamRun` straight back.
71
+ *
72
+ * Short-circuiting would require the facade to branch on the platform and would
73
+ * leave two reassembly paths whose equivalence is only a convention — while this
74
+ * way there is exactly one, exercised on every platform. It also equalizes the
75
+ * loss of generator backpressure that the native paths have regardless.
76
+ */
77
+ async startStream(options) {
78
+ if (!this.engine) {
79
+ throw createUnavailable("Capacitor IndeRun has not been configured. Configure providers before calling stream(request).").toContractError();
80
+ }
81
+ this.startingStreams.add(options.streamId);
82
+ let run;
83
+ try {
84
+ run = await this.engine.stream(options.request);
85
+ }
86
+ catch (error) {
87
+ this.startingStreams.delete(options.streamId);
88
+ this.pendingCancels.delete(options.streamId);
89
+ throw toIndeRunException(error).toContractError();
90
+ }
91
+ this.startingStreams.delete(options.streamId);
92
+ this.activeStreams.set(options.streamId, run);
93
+ if (this.pendingCancels.has(options.streamId)) {
94
+ const reason = this.pendingCancels.get(options.streamId);
95
+ this.pendingCancels.delete(options.streamId);
96
+ run.cancel(reason);
97
+ }
98
+ // Pump on a later microtask so this call's handle settles first.
99
+ queueMicrotask(() => void this.pump(options.streamId, run));
100
+ return run.handle;
101
+ }
102
+ async cancelStream(options) {
103
+ const run = this.activeStreams.get(options.streamId);
104
+ if (run !== undefined) {
105
+ // Never abandon the generator: the engine must stay free to emit its one
106
+ // `cancelled` terminal event.
107
+ run.cancel(options.reason);
108
+ return;
109
+ }
110
+ if (this.startingStreams.has(options.streamId)) {
111
+ // Cancel raced startStream's route selection; applied once the run attaches.
112
+ this.pendingCancels.set(options.streamId, options.reason);
113
+ return;
114
+ }
115
+ // Unknown or already-finished run: cancelling after the terminal is a no-op,
116
+ // not an error.
117
+ }
118
+ async pump(streamId, run) {
119
+ try {
120
+ for await (const event of run.events) {
121
+ this.notifyListeners(STREAM_EVENT_NAME, { streamId, event }, true);
122
+ }
123
+ }
124
+ catch (error) {
125
+ this.notifyListeners(STREAM_ERROR_NAME, { streamId, error: toIndeRunException(error).toContractError() }, true);
126
+ }
127
+ finally {
128
+ this.activeStreams.delete(streamId);
129
+ }
130
+ }
33
131
  }
34
- function compactOpenAIOptions(options) {
35
- const openAI = options.openAI;
132
+ /**
133
+ * Assigns only the fields the caller actually set. `exactOptionalPropertyTypes` makes
134
+ * this incremental style mandatory rather than cosmetic: assigning `undefined` to an
135
+ * optional property is an error, and an explicit `undefined` on the wire is not the same
136
+ * thing as an absent key to the provider that reads it.
137
+ *
138
+ * `id`, `healthCheckTimeoutMs` and `healthCheckCacheMs` are deliberately not bridged —
139
+ * all optional, and the bridge has no reason to expose provider-tuning knobs it cannot
140
+ * also offer on native.
141
+ */
142
+ function compactOpenAIOptions(openAI) {
36
143
  const result = {
37
144
  model: openAI.model
38
145
  };
@@ -50,3 +157,25 @@ function compactOpenAIOptions(options) {
50
157
  }
51
158
  return result;
52
159
  }
160
+ function compactSystemModelOptions(systemModel) {
161
+ const result = {};
162
+ if (systemModel.id !== undefined) {
163
+ result.id = systemModel.id;
164
+ }
165
+ if (systemModel.timeoutMs !== undefined) {
166
+ result.timeoutMs = systemModel.timeoutMs;
167
+ }
168
+ return result;
169
+ }
170
+ function compactOnnxOptions(onnx) {
171
+ const result = {
172
+ modelPackage: onnx.modelPackage
173
+ };
174
+ if (onnx.id !== undefined) {
175
+ result.id = onnx.id;
176
+ }
177
+ if (onnx.timeoutMs !== undefined) {
178
+ result.timeoutMs = onnx.timeoutMs;
179
+ }
180
+ return result;
181
+ }
@@ -36,9 +36,49 @@ struct CapacitorRunOptions: Codable {
36
36
  let allowDirectOpenAIEndpoint: Bool? // web-only, no-op on native
37
37
  }
38
38
 
39
+ /// Unlike `run(request)`, which takes the request at the options root, `startStream`
40
+ /// nests it under `request` so the envelope can also carry the bridge-local
41
+ /// `streamId`. See `StartStreamOptions` in src/definitions.ts.
42
+ struct CapacitorStartStreamOptions: Codable {
43
+ let streamId: String
44
+ let request: TaskRequest
45
+ }
46
+
47
+ struct CapacitorCancelStreamOptions: Codable {
48
+ let streamId: String
49
+ let reason: String?
50
+ }
51
+
52
+ /// IndeRunCore's `ProviderCapabilitySnapshot` is `Sendable` but not `Codable`, even
53
+ /// though both of its members are. This mirror exists only to get it through
54
+ /// `JSONEncoder`; see the upstream follow-up asking for `Codable` (and a schema) on
55
+ /// the snapshot itself.
56
+ private struct EncodableCapabilitySnapshot: Encodable {
57
+ let providerId: String
58
+ let descriptor: ProviderDescriptor
59
+ let capabilities: ProviderDynamicCapabilities
60
+ }
61
+
62
+ /// A Capacitor plugin method must resolve an object, never a top-level array, so the
63
+ /// snapshots travel wrapped. `IndeRunCapacitor` unwraps them on the JS side.
64
+ private struct CapabilitiesEnvelope: Encodable {
65
+ let providers: [EncodableCapabilitySnapshot]
66
+ }
67
+
68
+ /// Called with an already-encoded event/error for one run. The plugin turns these
69
+ /// into `notifyListeners` calls; taking them as closures keeps the pump testable
70
+ /// without Capacitor.
71
+ typealias StreamEventSink = @Sendable (String, JSObject) -> Void
72
+ typealias StreamErrorSink = @Sendable (String, JSObject) -> Void
73
+
39
74
  final class IndeRunCapacitorBridge {
40
75
  private var configuredRegistry: ProviderRegistry?
41
76
  private var configuredHostServices: HostServices?
77
+ let streams = StreamRegistry()
78
+
79
+ deinit {
80
+ streams.cancelAll(reason: "Capacitor bridge deallocated.")
81
+ }
42
82
 
43
83
  func configure(options: JSObject) throws {
44
84
  let runOptions = try decodeConfigureOptions(from: options)
@@ -58,10 +98,125 @@ final class IndeRunCapacitorBridge {
58
98
  return try encode(result)
59
99
  }
60
100
 
101
+ func checkCapabilities() async throws -> JSObject {
102
+ guard let registry = configuredRegistry, let hostServices = configuredHostServices else {
103
+ throw createUnavailable(message: "Capacitor IndeRun has not been configured. Configure providers before calling checkCapabilities().")
104
+ }
105
+
106
+ // IndeRun is a stateless coordinator; new per call is intentional — registry is cached above.
107
+ let engine = IndeRun(registry: registry, hostServices: hostServices)
108
+ return try encode(capabilities: await engine.checkCapabilities())
109
+ }
110
+
111
+ func startStream(
112
+ options: JSObject,
113
+ onEvent: @escaping StreamEventSink,
114
+ onError: @escaping StreamErrorSink
115
+ ) async throws -> JSObject {
116
+ guard let registry = configuredRegistry, let hostServices = configuredHostServices else {
117
+ throw createUnavailable(message: "Capacitor IndeRun has not been configured. Configure providers before calling stream(request).")
118
+ }
119
+
120
+ let start = try decodeStartStreamOptions(from: options)
121
+ // Reserved before the engine is reached, so a cancel arriving during route
122
+ // selection is recorded rather than dropped as an unknown id.
123
+ streams.open(streamId: start.streamId)
124
+
125
+ let run: StreamRun
126
+ do {
127
+ // IndeRun is a stateless coordinator; new per call is intentional — registry is cached above.
128
+ let engine = IndeRun(registry: registry, hostServices: hostServices)
129
+ run = try await engine.stream(request: start.request)
130
+ } catch {
131
+ streams.finish(streamId: start.streamId)
132
+ throw error
133
+ }
134
+
135
+ if case .cancelRequested(let reason) = streams.attach(streamId: start.streamId, run: run) {
136
+ run.cancel(reason: reason)
137
+ }
138
+
139
+ let streamId = start.streamId
140
+ // Weak, so a live run cannot keep the bridge alive past the plugin's deinit;
141
+ // the plugin's teardown cancels these tasks, which then release the bridge.
142
+ let task = Task { [weak self] in
143
+ guard let self else { return }
144
+ await self.pump(streamId: streamId, run: run, onEvent: onEvent, onError: onError)
145
+ }
146
+ streams.store(task: task, for: streamId)
147
+
148
+ return try encode(handle: run.handle)
149
+ }
150
+
151
+ func cancelStream(options: JSObject) throws {
152
+ let cancel = try decodeCancelStreamOptions(from: options)
153
+ streams.requestCancel(streamId: cancel.streamId, reason: cancel.reason)
154
+ }
155
+
156
+ func teardownStreams() {
157
+ streams.cancelAll(reason: "Capacitor plugin torn down.")
158
+ }
159
+
160
+ /// Forwards one run's canonical events to the sink until the stream ends.
161
+ ///
162
+ /// A provider failure has already become a terminal `error` event by the time it
163
+ /// reaches here — the engine's Event Gate owns that. Anything thrown out of the
164
+ /// sequence is a failure of this bridge, and is reported as one.
165
+ func pump(
166
+ streamId: String,
167
+ run: StreamRun,
168
+ onEvent: @escaping StreamEventSink,
169
+ onError: @escaping StreamErrorSink
170
+ ) async {
171
+ do {
172
+ for try await event in run.events {
173
+ onEvent(streamId, try encode(streamEvent: event))
174
+ }
175
+ } catch is CancellationError {
176
+ // Our own task cancellation, from teardown. The webview is going away and
177
+ // no one is left to receive a terminal event; not a bridge fault.
178
+ } catch {
179
+ let contractError = toIndeRunException(error).toContractError()
180
+ if let encoded = try? encode(error: contractError) {
181
+ onError(streamId, encoded)
182
+ }
183
+ }
184
+ streams.finish(streamId: streamId)
185
+ }
186
+
61
187
  func encode(error: IndeRunError) throws -> JSObject {
62
188
  try encodeObject(error)
63
189
  }
64
190
 
191
+ // JSONEncoder omits nil optionals, so "emit only the fields this event actually
192
+ // has" — which is what reconstitutes the right union branch on the JS side —
193
+ // comes for free from the generated Codable conformances.
194
+ func encode(streamEvent: StreamEvent) throws -> JSObject {
195
+ try encodeObject(streamEvent)
196
+ }
197
+
198
+ func encode(handle: StreamRunHandle) throws -> JSObject {
199
+ try encodeObject(handle)
200
+ }
201
+
202
+ /// The descriptor's enums carry explicit raw values (`in_process`, not `inProcess`),
203
+ /// and JSONEncoder omits nil optionals — so an unset `streamingAvailable` arrives as
204
+ /// an absent key rather than a null, which is what lets the JS side read absence as
205
+ /// "inherit `descriptor.supports.streaming`".
206
+ func encode(capabilities: [ProviderCapabilitySnapshot]) throws -> JSObject {
207
+ try encodeObject(
208
+ CapabilitiesEnvelope(
209
+ providers: capabilities.map {
210
+ EncodableCapabilitySnapshot(
211
+ providerId: $0.providerId,
212
+ descriptor: $0.descriptor,
213
+ capabilities: $0.capabilities
214
+ )
215
+ }
216
+ )
217
+ )
218
+ }
219
+
65
220
  private func makeRegistry(openAI: OpenAIProviderBootstrapOptions?) throws -> ProviderRegistry {
66
221
  let registry = ProviderRegistry()
67
222
  try registry.register(AppleFoundationModelsProvider())
@@ -89,6 +244,16 @@ final class IndeRunCapacitorBridge {
89
244
  return try JSONDecoder().decode(CapacitorRunOptions.self, from: data)
90
245
  }
91
246
 
247
+ private func decodeStartStreamOptions(from object: JSObject) throws -> CapacitorStartStreamOptions {
248
+ let data = try JSONSerialization.data(withJSONObject: object, options: [])
249
+ return try JSONDecoder().decode(CapacitorStartStreamOptions.self, from: data)
250
+ }
251
+
252
+ private func decodeCancelStreamOptions(from object: JSObject) throws -> CapacitorCancelStreamOptions {
253
+ let data = try JSONSerialization.data(withJSONObject: object, options: [])
254
+ return try JSONDecoder().decode(CapacitorCancelStreamOptions.self, from: data)
255
+ }
256
+
92
257
  private func decodeRequest(from object: JSObject) throws -> TaskRequest {
93
258
  let data = try JSONSerialization.data(withJSONObject: object, options: [])
94
259
  return try JSONDecoder().decode(TaskRequest.self, from: data)
@@ -101,10 +266,26 @@ final class IndeRunCapacitorBridge {
101
266
  private func encodeObject<T: Encodable>(_ value: T) throws -> JSObject {
102
267
  let data = try JSONEncoder().encode(value)
103
268
  let object = try JSONSerialization.jsonObject(with: data, options: [])
269
+
270
+ #if canImport(Capacitor)
271
+ // Capacitor's JSObject is [String: any JSValue], and a plain `as?` cast cannot
272
+ // produce one: JSONSerialization hands back NSDictionary/NSArray values, which do
273
+ // not conform to JSValue, so anything with a nested object or array — a TaskResult's
274
+ // `output`, a stream event's `payload`, the providers array — failed the cast and
275
+ // surfaced as "Capacitor bridge failed to encode a JSON object". JSTypes coerces the
276
+ // tree recursively, which is what the cast was standing in for.
277
+ guard let dictionary = object as? [AnyHashable: Any],
278
+ let coerced = JSTypes.coerceDictionaryToJSObject(dictionary) else {
279
+ throw createInternal(message: "Capacitor bridge failed to encode a JSON object.")
280
+ }
281
+ return coerced
282
+ #else
283
+ // Standalone builds alias JSObject to [String: Any], where the cast is exact.
104
284
  guard let dictionary = object as? JSObject else {
105
285
  throw createInternal(message: "Capacitor bridge failed to encode a JSON object.")
106
286
  }
107
287
  return dictionary
288
+ #endif
108
289
  }
109
290
 
110
291
  func mapAuthMode(_ value: String?) -> OpenAIAuthMode {
@@ -9,14 +9,24 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
9
9
  public let jsName = "IndeRunCapacitor"
10
10
  public let pluginMethods: [CAPPluginMethod] = [
11
11
  CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
12
- CAPPluginMethod(name: "run", returnType: CAPPluginReturnPromise)
12
+ CAPPluginMethod(name: "run", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "checkCapabilities", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "startStream", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "cancelStream", returnType: CAPPluginReturnPromise)
13
16
  ]
14
17
 
15
18
  private let implementation = IndeRunCapacitorBridge()
16
19
 
20
+ /// CAPPlugin has no teardown hook — `load()` has no counterpart — so deinit is
21
+ /// the only place left to stop runs the webview can no longer receive. The pump
22
+ /// tasks hold `self` weakly precisely so this can run.
23
+ deinit {
24
+ implementation.teardownStreams()
25
+ }
26
+
17
27
  @objc func configure(_ call: CAPPluginCall) {
18
28
  do {
19
- try implementation.configure(options: call.options)
29
+ try implementation.configure(options: call.jsObjectRepresentation)
20
30
  call.resolve()
21
31
  } catch let error as IndeRunException {
22
32
  let contractError = error.toContractError()
@@ -33,7 +43,7 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
33
43
  @objc func run(_ call: CAPPluginCall) {
34
44
  Task {
35
45
  do {
36
- let result = try await implementation.run(requestObject: call.options)
46
+ let result = try await implementation.run(requestObject: call.jsObjectRepresentation)
37
47
  call.resolve(result)
38
48
  } catch let error as IndeRunException {
39
49
  let contractError = error.toContractError()
@@ -47,5 +57,85 @@ public final class IndeRunCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
47
57
  }
48
58
  }
49
59
  }
60
+
61
+ /// Reports every registered provider's static declaration and live availability
62
+ /// without executing a task. Availability changes between calls — a local model can
63
+ /// unload, cloud credentials can expire — so callers must not cache it across a run.
64
+ @objc func checkCapabilities(_ call: CAPPluginCall) {
65
+ Task {
66
+ do {
67
+ call.resolve(try await implementation.checkCapabilities())
68
+ } catch let error as IndeRunException {
69
+ let contractError = error.toContractError()
70
+ let details = try? implementation.encode(error: contractError)
71
+ call.reject(contractError.message, contractError.errorClass.rawValue, error, details)
72
+ } catch {
73
+ let normalized = toIndeRunException(error)
74
+ let contractError = normalized.toContractError()
75
+ let details = try? implementation.encode(error: contractError)
76
+ call.reject(contractError.message, contractError.errorClass.rawValue, normalized, details)
77
+ }
78
+ }
79
+ }
80
+
81
+ /// Resolves with the run handle. Only validation and route-selection failures
82
+ /// reject here; a provider failure, a cancellation, or completion all arrive as
83
+ /// the single terminal event on `indeRunStreamEvent`.
84
+ ///
85
+ /// `retainUntilConsumed` closes the listener-registration race from the native
86
+ /// side: an event emitted before the JS listener is attached is retained and
87
+ /// replayed rather than lost.
88
+ @objc func startStream(_ call: CAPPluginCall) {
89
+ Task { [weak self] in
90
+ guard let self else { return }
91
+ do {
92
+ let handle = try await self.implementation.startStream(
93
+ options: call.jsObjectRepresentation,
94
+ onEvent: { [weak self] streamId, event in
95
+ self?.notifyListeners(
96
+ "indeRunStreamEvent",
97
+ data: ["streamId": streamId, "event": event],
98
+ retainUntilConsumed: true
99
+ )
100
+ },
101
+ onError: { [weak self] streamId, error in
102
+ self?.notifyListeners(
103
+ "indeRunStreamError",
104
+ data: ["streamId": streamId, "error": error],
105
+ retainUntilConsumed: true
106
+ )
107
+ }
108
+ )
109
+ call.resolve(handle)
110
+ } catch let error as IndeRunException {
111
+ let contractError = error.toContractError()
112
+ let details = try? self.implementation.encode(error: contractError)
113
+ call.reject(contractError.message, contractError.errorClass.rawValue, error, details)
114
+ } catch {
115
+ let normalized = toIndeRunException(error)
116
+ let contractError = normalized.toContractError()
117
+ let details = try? self.implementation.encode(error: contractError)
118
+ call.reject(contractError.message, contractError.errorClass.rawValue, normalized, details)
119
+ }
120
+ }
121
+ }
122
+
123
+ /// Resolves for an unknown or already-finished run: cancelling after the terminal
124
+ /// is a no-op by contract, not an error.
125
+ @objc func cancelStream(_ call: CAPPluginCall) {
126
+ do {
127
+ try implementation.cancelStream(options: call.jsObjectRepresentation)
128
+ call.resolve()
129
+ } catch let error as IndeRunException {
130
+ let contractError = error.toContractError()
131
+ let details = try? implementation.encode(error: contractError)
132
+ call.reject(contractError.message, contractError.errorClass.rawValue, error, details)
133
+ } catch {
134
+ let normalized = toIndeRunException(error)
135
+ let contractError = normalized.toContractError()
136
+ let details = try? implementation.encode(error: contractError)
137
+ call.reject(contractError.message, contractError.errorClass.rawValue, normalized, details)
138
+ }
139
+ }
50
140
  }
51
141
  #endif
@@ -0,0 +1,121 @@
1
+ import Foundation
2
+ import IndeRunCore
3
+
4
+ /// Tracks the streaming runs the bridge is currently pumping, keyed by the
5
+ /// bridge-local `streamId`.
6
+ ///
7
+ /// Two things make this more than a dictionary. A cancel can arrive before the run
8
+ /// it refers to exists — `startStream` has to reach route selection before there is
9
+ /// anything to cancel — so a cancel requested in that window is recorded and applied
10
+ /// on attach. And cancelling a run is *not* cancelling the pump task: the engine
11
+ /// answers a cancel with its one `cancelled` terminal event, which the pump still has
12
+ /// to deliver. Task cancellation is reserved for teardown, where the webview is going
13
+ /// away and nobody is left to receive a terminal.
14
+ ///
15
+ /// Deliberately free of any Capacitor dependency, so it is exercised by `swift test`
16
+ /// on macOS where `canImport(Capacitor)` is false.
17
+ final class StreamRegistry {
18
+ enum AttachOutcome: Equatable {
19
+ case attached
20
+ /// A cancel arrived before the run did; the caller must apply it.
21
+ case cancelRequested(reason: String?)
22
+ }
23
+
24
+ private struct Entry {
25
+ var run: StreamRun?
26
+ var task: Task<Void, Never>?
27
+ var cancelRequested = false
28
+ var cancelReason: String?
29
+ }
30
+
31
+ private let lock = NSLock()
32
+ private var entries: [String: Entry] = [:]
33
+
34
+ var activeCount: Int {
35
+ lock.lock()
36
+ defer { lock.unlock() }
37
+ return entries.count
38
+ }
39
+
40
+ /// Reserves the id so a cancel arriving before the run can be recorded rather
41
+ /// than dropped as unknown.
42
+ func open(streamId: String) {
43
+ lock.lock()
44
+ defer { lock.unlock() }
45
+ if entries[streamId] == nil {
46
+ entries[streamId] = Entry()
47
+ }
48
+ }
49
+
50
+ func attach(streamId: String, run: StreamRun) -> AttachOutcome {
51
+ lock.lock()
52
+ guard var entry = entries[streamId] else {
53
+ // Torn down while the engine was still selecting a route.
54
+ lock.unlock()
55
+ return .cancelRequested(reason: nil)
56
+ }
57
+ if entry.cancelRequested {
58
+ let reason = entry.cancelReason
59
+ lock.unlock()
60
+ return .cancelRequested(reason: reason)
61
+ }
62
+ entry.run = run
63
+ entries[streamId] = entry
64
+ lock.unlock()
65
+ return .attached
66
+ }
67
+
68
+ func store(task: Task<Void, Never>, for streamId: String) {
69
+ lock.lock()
70
+ defer { lock.unlock() }
71
+ guard var entry = entries[streamId] else {
72
+ task.cancel()
73
+ return
74
+ }
75
+ entry.task = task
76
+ entries[streamId] = entry
77
+ }
78
+
79
+ /// Cancels the run if it has attached, otherwise records the request for attach.
80
+ /// An unknown id — already finished, or never started — is a silent no-op, which
81
+ /// is what makes cancelling after the terminal harmless.
82
+ func requestCancel(streamId: String, reason: String?) {
83
+ lock.lock()
84
+ guard var entry = entries[streamId] else {
85
+ lock.unlock()
86
+ return
87
+ }
88
+ if entry.cancelRequested {
89
+ lock.unlock()
90
+ return
91
+ }
92
+ entry.cancelRequested = true
93
+ entry.cancelReason = reason
94
+ let run = entry.run
95
+ entries[streamId] = entry
96
+ lock.unlock()
97
+
98
+ // Never under the lock: cancel reaches into engine code.
99
+ run?.cancel(reason: reason)
100
+ }
101
+
102
+ func finish(streamId: String) {
103
+ lock.lock()
104
+ defer { lock.unlock() }
105
+ entries.removeValue(forKey: streamId)
106
+ }
107
+
108
+ /// Teardown. Cancels each run first so providers unwind, then the pump tasks,
109
+ /// which at this point have nobody left to deliver to.
110
+ func cancelAll(reason: String?) {
111
+ lock.lock()
112
+ let draining = entries
113
+ entries.removeAll()
114
+ lock.unlock()
115
+
116
+ for (_, entry) in draining {
117
+ entry.run?.cancel(reason: reason)
118
+ entry.task?.cancel()
119
+ }
120
+ }
121
+ }