@tokamakdev/platform-ios-simulator-x64 0.1.1-beta.107 → 0.1.1-beta.114

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.
@@ -1,14 +1,25 @@
1
1
  import Foundation
2
2
  import WebKit
3
3
 
4
+ #if os(iOS)
5
+ import UIKit
6
+ #else
7
+ import AppKit
8
+ #endif
9
+
4
10
  struct TokamakPluginError: Error {
5
11
  let name: String
6
12
  let message: String
13
+ /// JSON details the Worker receives as the error's `cause`.
14
+ var cause: Any? = nil
7
15
 
8
16
  static func notSupported(_ message: String) -> Self {
9
17
  Self(name: "NotSupportedError", message: message)
10
18
  }
11
19
 
20
+ /// A native screen was needed while the app has no window.
21
+ static let noWindow = Self(name: "InvalidStateError", message: "The app has no window")
22
+
12
23
  /// UI was needed while the app cannot show it.
13
24
  static let needsUI = Self(
14
25
  name: "NeedsUIError",
@@ -17,6 +28,68 @@ struct TokamakPluginError: Error {
17
28
  }
18
29
 
19
30
  typealias TokamakPluginReply = (Result<Any?, TokamakPluginError>) -> Void
31
+ typealias TokamakPluginBytesReply = (TokamakPluginResult) -> Void
32
+
33
+ struct TokamakPluginResult {
34
+ let payload: Data
35
+ let failed: Bool
36
+ let done: Bool
37
+
38
+ init(payload: Data, failed: Bool, done: Bool) {
39
+ self.payload = payload
40
+ self.failed = failed
41
+ self.done = done
42
+ }
43
+
44
+ init(_ result: Result<Data, TokamakPluginError>, done: Bool) {
45
+ self.done = done
46
+ switch result {
47
+ case .success(let payload):
48
+ self.payload = payload
49
+ failed = false
50
+ case .failure(let error):
51
+ var failure: [String: Any] = ["name": error.name, "message": error.message]
52
+ failure["cause"] = error.cause
53
+ payload = try! JSONSerialization.data(withJSONObject: failure)
54
+ failed = true
55
+ }
56
+ }
57
+ }
58
+
59
+ /// `value` as a payload: `Data` as it is, and any other value as JSON.
60
+ func encodePluginValue(_ value: Any?) -> Result<Data, TokamakPluginError> {
61
+ if let data = value as? Data { return .success(data) }
62
+ let value = value ?? NSNull()
63
+ // JSONSerialization raises, rather than throws, for values it cannot encode.
64
+ guard JSONSerialization.isValidJSONObject([value]),
65
+ let json = try? JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed)
66
+ else {
67
+ return .failure(.init(name: "DataCloneError", message: "The value cannot be encoded as JSON"))
68
+ }
69
+ return .success(json)
70
+ }
71
+
72
+ /// Decodes `payload` as JSON for `call`, and encodes its reply.
73
+ private func callJSON(
74
+ _ payload: Data,
75
+ _ reply: @escaping TokamakPluginBytesReply,
76
+ _ call: (Any, @escaping TokamakPluginReply) -> (() -> Void)?
77
+ ) -> (() -> Void)? {
78
+ do {
79
+ return call(try decodePluginValue(payload)) {
80
+ reply(TokamakPluginResult($0.flatMap(encodePluginValue), done: true))
81
+ }
82
+ } catch {
83
+ reply(TokamakPluginResult(.failure(error), done: true))
84
+ return nil
85
+ }
86
+ }
87
+
88
+ func decodePluginValue(_ payload: Data) throws(TokamakPluginError) -> Any {
89
+ do { return try JSONSerialization.jsonObject(with: payload, options: .fragmentsAllowed) } catch {
90
+ throw .init(name: "DataCloneError", message: error.localizedDescription)
91
+ }
92
+ }
20
93
 
21
94
  /// The app as one plugin sees it.
22
95
  final class TokamakHost {
@@ -28,6 +101,11 @@ final class TokamakHost {
28
101
  self.plugin = plugin
29
102
  }
30
103
 
104
+ var webView: WKWebView? { app.webView }
105
+
106
+ /// The native objects plugins share with each other by ID.
107
+ var resources: TokamakResources { app.resources }
108
+
31
109
  /// Throws `NeedsUIError` unless the app can show UI now: on iOS, while it
32
110
  /// is in the foreground. A hidden or minimised macOS app still shows system
33
111
  /// prompts. Call it on the main thread, where the plugin decides to show
@@ -38,18 +116,61 @@ final class TokamakHost {
38
116
  #endif
39
117
  }
40
118
 
119
+ #if os(iOS)
120
+ /// The view controller to present a native screen from: the top of the
121
+ /// app window's presentation stack. Throws `NeedsUIError` unless the app
122
+ /// can show UI now. Call it on the main thread.
123
+ func presenter() throws(TokamakPluginError) -> UIViewController {
124
+ try requireUI()
125
+ guard var top = webView?.window?.rootViewController else { throw .noWindow }
126
+ while let presented = top.presentedViewController { top = presented }
127
+ return top
128
+ }
129
+ #else
130
+ /// The app window, to present a native screen over. Call it on the main
131
+ /// thread.
132
+ func presenter() throws(TokamakPluginError) -> NSWindow {
133
+ guard let window = webView?.window else { throw .noWindow }
134
+ return window
135
+ }
136
+ #endif
137
+
41
138
  /// Delivers the plugin's event `name`, which the Worker's listeners
42
- /// receive as `<plugin>.<name>`, with `event`, JSON-serialisable, and calls
43
- /// `completion` on the main thread with their reply, parsed from JSON, or
44
- /// nil. Fails unless they return within `timeout`, which includes runtime
45
- /// startup. Call it on the main thread.
139
+ /// receive as `<plugin>.<name>`, with its encoded `payload`, and calls
140
+ /// `completion` on the main thread with their encoded reply, which is empty
141
+ /// when none replied. Fails unless they return within `timeout`, which
142
+ /// includes runtime startup. Call it on the main thread.
143
+ func emitBytes(
144
+ _ name: String,
145
+ payload: Data,
146
+ timeout: TimeInterval,
147
+ completion: @escaping (Result<Data, Error>) -> Void
148
+ ) {
149
+ app.emit("\(plugin).\(name)", payload: payload, timeout: timeout, completion: completion)
150
+ }
151
+
152
+ /// `emitBytes` with `event` encoded as JSON, calling `completion` with the
153
+ /// reply parsed from JSON, or nil when none replied.
46
154
  func emit(
47
155
  _ name: String,
48
156
  event: Any,
49
157
  timeout: TimeInterval,
50
158
  completion: @escaping (Result<Any?, Error>) -> Void
51
159
  ) {
52
- app.emit("\(plugin).\(name)", event: event, timeout: timeout, completion: completion)
160
+ let payload: Data
161
+ switch encodePluginValue(event) {
162
+ case .success(let encoded): payload = encoded
163
+ case .failure(let error): return completion(.failure(error))
164
+ }
165
+ emitBytes(name, payload: payload, timeout: timeout) { result in
166
+ completion(
167
+ result.flatMap { reply in
168
+ Result { () throws(TokamakPluginError) -> Any? in
169
+ let value = reply.isEmpty ? NSNull() : try decodePluginValue(reply)
170
+ return value is NSNull ? nil : value
171
+ }.mapError { $0 }
172
+ })
173
+ }
53
174
  }
54
175
 
55
176
  /// Fetches what the app serves at `path`, as its WebView would load it: a
@@ -65,18 +186,40 @@ final class TokamakHost {
65
186
  }
66
187
  }
67
188
 
68
- /// A native plugin, created once per process when the app launches. The page
69
- /// and the Worker call it on the main thread.
189
+ /// A native plugin, created once per process.
70
190
  protocol TokamakPlugin: AnyObject {
71
191
  var id: String { get }
72
192
 
73
193
  init(host: TokamakHost)
74
194
 
195
+ /// Serial executor for this operation and its cancellation.
196
+ func executor(for method: String) -> DispatchQueue
197
+
198
+ func callBytes(method: String, payload: Data, reply: @escaping TokamakPluginBytesReply) -> (
199
+ () -> Void
200
+ )?
201
+ func callBytes(
202
+ method: String, payload: Data, body: TokamakPluginBody,
203
+ reply: @escaping TokamakPluginBytesReply
204
+ ) -> (() -> Void)?
205
+ func subscribeBytes(method: String, payload: Data, reply: @escaping TokamakPluginBytesReply)
206
+ throws(TokamakPluginError) -> (() -> Void)
207
+
208
+ /// Returns cancellation for pending work when the platform supports it.
75
209
  func call(
76
210
  method: String,
77
211
  arguments: Any,
78
212
  reply: @escaping TokamakPluginReply
79
- )
213
+ ) -> (() -> Void)?
214
+
215
+ /// A call whose `body` of bytes the Worker streams, which the plugin reads
216
+ /// one chunk at a time.
217
+ func call(
218
+ method: String,
219
+ arguments: Any,
220
+ body: TokamakPluginBody,
221
+ reply: @escaping TokamakPluginReply
222
+ ) -> (() -> Void)?
80
223
 
81
224
  /// Replies with each value until the returned function is called. Throws
82
225
  /// when the subscription cannot start, which ends it.
@@ -99,12 +242,46 @@ protocol TokamakPlugin: AnyObject {
99
242
  }
100
243
 
101
244
  extension TokamakPlugin {
245
+ func executor(for method: String) -> DispatchQueue { .main }
246
+
247
+ func callBytes(method: String, payload: Data, reply: @escaping TokamakPluginBytesReply) -> (
248
+ () -> Void
249
+ )? {
250
+ callJSON(payload, reply) { call(method: method, arguments: $0, reply: $1) }
251
+ }
252
+
253
+ func callBytes(
254
+ method: String, payload: Data, body: TokamakPluginBody,
255
+ reply: @escaping TokamakPluginBytesReply
256
+ ) -> (() -> Void)? {
257
+ callJSON(payload, reply) { call(method: method, arguments: $0, body: body, reply: $1) }
258
+ }
259
+
260
+ func subscribeBytes(method: String, payload: Data, reply: @escaping TokamakPluginBytesReply)
261
+ throws(TokamakPluginError) -> (() -> Void)
262
+ {
263
+ try subscribe(method: method, arguments: decodePluginValue(payload)) {
264
+ reply(TokamakPluginResult($0.flatMap(encodePluginValue), done: false))
265
+ }
266
+ }
267
+
102
268
  func call(
103
269
  method: String,
104
270
  arguments: Any,
105
271
  reply: @escaping TokamakPluginReply
106
- ) {
272
+ ) -> (() -> Void)? {
107
273
  reply(.failure(.notSupported("\(id).\(method) is not supported")))
274
+ return nil
275
+ }
276
+
277
+ func call(
278
+ method: String,
279
+ arguments: Any,
280
+ body: TokamakPluginBody,
281
+ reply: @escaping TokamakPluginReply
282
+ ) -> (() -> Void)? {
283
+ reply(.failure(.notSupported("\(id).\(method) does not take a body")))
284
+ return nil
108
285
  }
109
286
 
110
287
  func subscribe(
@@ -127,183 +304,157 @@ extension TokamakPlugin {
127
304
  }
128
305
  }
129
306
 
130
- /// Runs one caller's calls and subscriptions, the page's or the Worker's, on
131
- /// the app's plugins, keyed by `Key`. `send` receives each result on the main
132
- /// thread, as the JSON object the page's native transport receives without its
133
- /// session and ID. Use it on the main thread.
134
- final class TokamakPluginRequests<Key: Hashable> {
135
- private let plugins: [String: any TokamakPlugin]
136
- private let send: (Key, [String: Any]) -> Void
137
- private var cancellations: [Key: () -> Void] = [:]
307
+ /// The bytes the Worker streams to a call, which the plugin reads one chunk
308
+ /// at a time.
309
+ final class TokamakPluginBody {
310
+ typealias Chunk = (Result<Data?, TokamakPluginError>) -> Void
138
311
 
139
- init(plugins: [String: any TokamakPlugin], send: @escaping (Key, [String: Any]) -> Void) {
140
- self.plugins = plugins
141
- self.send = send
142
- }
143
-
144
- /// Calls `method` of `plugin`, or subscribes to it when `subscribe`.
145
- func run(_ key: Key, subscribe: Bool, plugin: String, method: String, arguments: Any) {
146
- guard let target = plugins[plugin], !method.isEmpty else {
147
- fail(key, .notSupported("Plugin is not supported"))
148
- return
149
- }
150
- // Plugins reply from any thread.
151
- let reply: TokamakPluginReply = { [weak self] result in
152
- DispatchQueue.main.async {
153
- self?.send(key, Self.result(result, done: !subscribe))
154
- }
155
- }
156
- guard subscribe else {
157
- target.call(method: method, arguments: arguments, reply: reply)
158
- return
159
- }
160
- do {
161
- cancellations[key] = try target.subscribe(method: method, arguments: arguments, reply: reply)
162
- } catch {
163
- fail(key, error)
164
- }
165
- }
312
+ private let queue: DispatchQueue
313
+ private let pull: () -> Void
314
+ private let lock = NSLock()
315
+ private var reading: Chunk?
166
316
 
167
- /// Ends the request `key` with `error`.
168
- func fail(_ key: Key, _ error: TokamakPluginError) {
169
- send(key, Self.result(.failure(error), done: true))
317
+ init(queue: DispatchQueue, pull: @escaping () -> Void) {
318
+ self.queue = queue
319
+ self.pull = pull
170
320
  }
171
321
 
172
- /// Ends the subscription `key`.
173
- func cancel(_ key: Key) {
174
- cancellations.removeValue(forKey: key)?()
322
+ /// Reads the next chunk: its bytes, nil after the last, or the Worker's
323
+ /// error. `handler` runs on the call's executor; read again only after it
324
+ /// has.
325
+ func read(_ handler: @escaping Chunk) {
326
+ lock.withLock { reading = handler }
327
+ pull()
175
328
  }
176
329
 
177
- /// Ends every subscription.
178
- func cancelAll() {
179
- let cancelAll = Array(cancellations.values)
180
- cancellations.removeAll()
181
- for cancel in cancelAll {
182
- cancel()
330
+ /// Answers the pending read with the chunk the Worker sent.
331
+ fileprivate func receive(_ chunk: TokamakPluginResult) {
332
+ let handler = lock.withLock {
333
+ defer { reading = nil }
334
+ return reading
183
335
  }
184
- }
185
-
186
- /// `result` as JSON, or the failure to make it JSON.
187
- private static func result(
188
- _ result: Result<Any?, TokamakPluginError>,
189
- done: Bool
190
- ) -> [String: Any] {
191
- switch result {
192
- case .success(let value):
193
- let json: [String: Any] = ["value": value ?? NSNull(), "done": done]
194
- if JSONSerialization.isValidJSONObject(json) {
195
- return json
336
+ guard let handler else { return }
337
+ queue.async {
338
+ if chunk.failed {
339
+ let error = try? JSONSerialization.jsonObject(with: chunk.payload) as? [String: String]
340
+ handler(.failure(.init(name: error?["name"] ?? "Error", message: error?["message"] ?? "")))
341
+ } else {
342
+ handler(.success(chunk.done ? nil : chunk.payload))
196
343
  }
197
- let error = TokamakPluginError(
198
- name: "OperationError", message: "The plugin's value is not JSON")
199
- return Self.result(.failure(error), done: done)
200
- case .failure(let error):
201
- return ["error": ["name": error.name, "message": error.message], "done": done]
202
344
  }
203
345
  }
204
346
  }
205
347
 
206
- final class TokamakPluginBridge: NSObject, WKScriptMessageHandler {
207
- private struct RequestKey: Hashable {
208
- let session: String
209
- let id: Int
348
+ /// What the Worker asked a plugin for.
349
+ enum TokamakPluginRequest {
350
+ case call, callWithBody, subscribe
351
+ }
352
+
353
+ /// Runs the Worker's requests on the executor each plugin chooses, and passes
354
+ /// replies straight to the runtime.
355
+ final class TokamakPluginRequests<Key: Hashable> {
356
+ /// A pending call or subscription. Only its serial executor touches
357
+ /// `cancellation`, so a cancellation runs after the operation started.
358
+ private final class Request {
359
+ let queue: DispatchQueue
360
+ var body: TokamakPluginBody?
361
+ var cancellation: (() -> Void)?
362
+ init(queue: DispatchQueue) { self.queue = queue }
210
363
  }
211
364
 
212
- private let host: String
213
365
  private let plugins: [String: any TokamakPlugin]
214
- private lazy var requests = TokamakPluginRequests<RequestKey>(plugins: plugins) {
215
- [weak self] key, result in
216
- self?.deliver(key: key, result: result)
366
+ private let send: (Key, TokamakPluginResult) -> Void
367
+ private let pull: (Key) -> Void
368
+ private let lock = NSLock()
369
+ private var pending: [Key: Request] = [:]
370
+
371
+ /// Routes requests to `plugins`, sending their results with `send` and
372
+ /// asking the Worker for the next chunk of a call's body with `pull`.
373
+ init(
374
+ plugins: [String: any TokamakPlugin],
375
+ send: @escaping (Key, TokamakPluginResult) -> Void,
376
+ pull: @escaping (Key) -> Void = { _ in }
377
+ ) {
378
+ self.plugins = plugins
379
+ self.send = send
380
+ self.pull = pull
217
381
  }
218
- weak var webView: WKWebView?
219
382
 
220
- init(host: String, plugins: [String: any TokamakPlugin]) {
221
- self.host = host
222
- self.plugins = plugins
383
+ func run(
384
+ _ key: Key, _ kind: TokamakPluginRequest, plugin: String, method: String, payload: Data
385
+ ) {
386
+ guard let target = plugins[plugin], !method.isEmpty else {
387
+ send(key, TokamakPluginResult(.failure(.notSupported("Plugin is not supported")), done: true))
388
+ return
389
+ }
390
+ let request = Request(queue: target.executor(for: method))
391
+ if kind == .callWithBody {
392
+ request.body = TokamakPluginBody(queue: request.queue) { [weak self] in self?.pull(key) }
393
+ }
394
+ lock.withLock { pending[key] = request }
395
+ let subscribe = kind == .subscribe
396
+ // A weak request lets a completed call release its cancellation.
397
+ let reply: TokamakPluginBytesReply = { [weak self, weak request] result in
398
+ guard let self, let request else { return }
399
+ let last = !subscribe || result.done
400
+ guard last ? remove(key, request) : isPending(key, request) else { return }
401
+ send(key, result)
402
+ if subscribe && result.done { cancel(request) }
403
+ }
404
+ request.queue.async { [self] in
405
+ guard isPending(key, request) else { return }
406
+ do throws(TokamakPluginError) {
407
+ if subscribe {
408
+ request.cancellation = try target.subscribeBytes(
409
+ method: method, payload: payload, reply: reply)
410
+ } else if let body = request.body {
411
+ request.cancellation = target.callBytes(
412
+ method: method, payload: payload, body: body, reply: reply)
413
+ } else {
414
+ request.cancellation = target.callBytes(method: method, payload: payload, reply: reply)
415
+ }
416
+ } catch {
417
+ if remove(key, request) { send(key, TokamakPluginResult(.failure(error), done: true)) }
418
+ }
419
+ }
223
420
  }
224
421
 
225
- func install(in controller: WKUserContentController) {
226
- controller.add(self, name: "tokamak")
227
- controller.addUserScript(
228
- WKUserScript(
229
- source: Self.bootstrap(host: host),
230
- injectionTime: .atDocumentStart,
231
- forMainFrameOnly: true
232
- )
233
- )
422
+ /// Answers the pending read of the call `key`'s body with the chunk the
423
+ /// Worker sent.
424
+ func chunk(_ key: Key, _ chunk: TokamakPluginResult) {
425
+ lock.withLock { pending[key]?.body }?.receive(chunk)
234
426
  }
235
427
 
236
- func close() {
237
- requests.cancelAll()
428
+ func cancel(_ key: Key) {
429
+ if let request = lock.withLock({ pending.removeValue(forKey: key) }) { cancel(request) }
238
430
  }
239
431
 
240
- func userContentController(
241
- _ userContentController: WKUserContentController,
242
- didReceive message: WKScriptMessage
243
- ) {
244
- guard
245
- message.frameInfo.isMainFrame,
246
- message.frameInfo.securityOrigin.isAppOrigin(host),
247
- let encoded = message.body as? String,
248
- let data = encoded.data(using: .utf8),
249
- let request = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
250
- let type = request["type"] as? String,
251
- let session = request["session"] as? String
252
- else {
253
- return
432
+ func cancelAll() {
433
+ let requests = lock.withLock {
434
+ defer { pending.removeAll() }
435
+ return Array(pending.values)
254
436
  }
437
+ for request in requests { cancel(request) }
438
+ }
255
439
 
256
- if type == "reset" {
257
- close()
258
- return
259
- }
260
- guard let id = request["id"] as? Int else { return }
261
- let key = RequestKey(session: session, id: id)
262
- switch type {
263
- case "cancel":
264
- requests.cancel(key)
265
- case "call", "subscribe":
266
- requests.run(
267
- key,
268
- subscribe: type == "subscribe",
269
- plugin: request["plugin"] as? String ?? "",
270
- method: request["method"] as? String ?? "",
271
- arguments: request["arguments"] ?? NSNull()
272
- )
273
- default:
274
- requests.fail(key, .notSupported("Plugin operation is not supported"))
440
+ /// Runs the operation's cancellation on its executor.
441
+ private func cancel(_ request: Request) {
442
+ request.queue.async {
443
+ request.cancellation?()
444
+ request.cancellation = nil
275
445
  }
276
446
  }
277
447
 
278
- private func deliver(key: RequestKey, result: [String: Any]) {
279
- var response = result
280
- response["session"] = key.session
281
- response["id"] = key.id
282
- guard
283
- let data = try? JSONSerialization.data(withJSONObject: response),
284
- let json = String(data: data, encoding: .utf8),
285
- webView?.url?.isAppOrigin(host) == true
286
- else {
287
- return
288
- }
289
- webView?.callAsyncJavaScript(
290
- "globalThis.__tokamakNative?.onmessage?.({ data })",
291
- arguments: ["data": json],
292
- in: nil,
293
- in: .page
294
- )
448
+ private func isPending(_ key: Key, _ request: Request) -> Bool {
449
+ lock.withLock { pending[key] === request }
295
450
  }
296
451
 
297
- private static func bootstrap(host: String) -> String {
298
- """
299
- if (globalThis.location.origin === "https://\(host)") {
300
- globalThis.__tokamakNative = {
301
- onmessage: null,
302
- postMessage(message) {
303
- globalThis.webkit.messageHandlers.tokamak.postMessage(message);
304
- }
305
- };
452
+ /// Removes `request`, returning whether it was pending.
453
+ private func remove(_ key: Key, _ request: Request) -> Bool {
454
+ lock.withLock {
455
+ guard pending[key] === request else { return false }
456
+ pending[key] = nil
457
+ return true
306
458
  }
307
- """
308
459
  }
309
460
  }
@@ -0,0 +1,74 @@
1
+ import Foundation
2
+
3
+ /// Native objects that plugins share by ID. Each belongs to a scope and is
4
+ /// kept until it is released or its scope ends.
5
+ final class TokamakResources {
6
+ private let lock = NSLock()
7
+ private var objects: [String: (object: Any, scope: String)] = [:]
8
+ private var scopes: [String: Set<String>] = [:]
9
+
10
+ /// Starts a scope and returns its ID.
11
+ func open() -> String {
12
+ let scope = UUID().uuidString
13
+ lock.withLock { scopes[scope] = [] }
14
+ return scope
15
+ }
16
+
17
+ /// Keeps `object` in `scope` and returns its ID. An ended scope keeps
18
+ /// nothing, so the ID finds no object.
19
+ func retain(_ object: Any, in scope: String) -> String {
20
+ let id = UUID().uuidString
21
+ lock.withLock {
22
+ guard scopes[scope]?.insert(id) != nil else { return }
23
+ objects[id] = (object, scope)
24
+ }
25
+ return id
26
+ }
27
+
28
+ /// Keeps `object` in a scope of its own, replies with its ID, and returns
29
+ /// the release to return from `subscribe`.
30
+ func retain(_ object: Any, reply: TokamakPluginReply) -> () -> Void {
31
+ let scope = open()
32
+ reply(.success(retain(object, in: scope)))
33
+ return { [self] in end(scope) }
34
+ }
35
+
36
+ /// Releases the object retained as `id`, if it is still kept.
37
+ func release(_ id: Any) {
38
+ let removed = lock.withLock { () -> Any? in
39
+ guard let id = id as? String, let entry = objects.removeValue(forKey: id) else { return nil }
40
+ scopes[entry.scope]?.remove(id)
41
+ return entry.object
42
+ }
43
+ drop(removed)
44
+ }
45
+
46
+ /// Releases every object `scope` keeps, and keeps nothing in it later.
47
+ func end(_ scope: String) {
48
+ let removed = lock.withLock { () -> [Any] in
49
+ (scopes.removeValue(forKey: scope) ?? []).compactMap {
50
+ objects.removeValue(forKey: $0)?.object
51
+ }
52
+ }
53
+ drop(removed)
54
+ }
55
+
56
+ /// The object retained as `id`.
57
+ func get<Object>(_ id: Any, as type: Object.Type) throws(TokamakPluginError) -> Object {
58
+ guard let id = id as? String, let object = lock.withLock({ objects[id]?.object }) else {
59
+ throw TokamakPluginError(name: "InvalidStateError", message: "The native reference is closed")
60
+ }
61
+ guard let typed = object as? Object else {
62
+ throw TokamakPluginError(
63
+ name: "TypeError", message: "The native reference is not \(Object.self)")
64
+ }
65
+ return typed
66
+ }
67
+
68
+ /// Lets go of released objects on the main thread, where UI and run loop
69
+ /// objects expect to be deallocated.
70
+ private func drop(_ objects: Any?) {
71
+ guard let objects else { return }
72
+ DispatchQueue.main.async { withExtendedLifetime(objects) {} }
73
+ }
74
+ }