@tokamakdev/platform-ios-simulator-x64 0.1.1-beta.82 → 0.1.1-beta.84

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.
@@ -172,14 +172,14 @@ fi
172
172
  # sources are Swift.
173
173
  swift_type='^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$'
174
174
  registry=$temporary/TokamakPluginRegistry.swift
175
- printf '%s\n\n%s\n%s\n' 'import Foundation' 'func tokamakPlugins(host: TokamakHost) -> [any TokamakPlugin] {' ' [' > "$registry"
175
+ printf '%s\n\n%s\n%s\n' 'import Foundation' 'func tokamakPlugins(app: TokamakApp) -> [any TokamakPlugin] {' ' [' > "$registry"
176
176
  for plugin in "$input/plugins"/*/; do
177
177
  class=$(cat "$plugin/class" 2>/dev/null || true)
178
178
  if [[ ! "$class" =~ $swift_type ]]; then
179
179
  echo "plugin '$(basename "$plugin")' has invalid $platform class '$class'" >&2
180
180
  exit 1
181
181
  fi
182
- printf ' %s(host: host),\n' "$class" >> "$registry"
182
+ printf ' %s(host: TokamakHost(app: app, plugin: "%s")),\n' "$class" "$(basename "$plugin")" >> "$registry"
183
183
  for source in "$plugin/sources"/*; do
184
184
  case "$source" in
185
185
  *.swift) swift_sources+=("$source") ;;
@@ -21,21 +21,36 @@ typedef struct {
21
21
  TokamakBytes private_key;
22
22
  } TokamakIdentity;
23
23
 
24
+ typedef void (*TokamakPluginRequest)(void *context, uint64_t id,
25
+ const char *plugin, const char *method,
26
+ const char *arguments);
27
+
28
+ typedef struct {
29
+ void *context;
30
+ TokamakPluginRequest call;
31
+ TokamakPluginRequest subscribe;
32
+ void (*unsubscribe)(void *context, uint64_t id);
33
+ } TokamakPluginHandler;
34
+
24
35
  void *tokamak_runtime_start(const char *packaged_dir, const char *state_dir,
25
36
  const char *storage_dir, const char *host,
37
+ bool foreground, TokamakPluginHandler plugins,
26
38
  char *error, size_t error_len);
27
39
  void *tokamak_runtime_start_development(const char *state_dir, const char *host,
28
40
  const char *endpoint,
29
- const char *session_token, char *error,
30
- size_t error_len);
41
+ const char *session_token,
42
+ bool foreground,
43
+ TokamakPluginHandler plugins,
44
+ char *error, size_t error_len);
31
45
  uint16_t tokamak_runtime_port(const void *runtime);
32
46
  uint16_t tokamak_runtime_restore_gateway(const void *runtime, char *error,
33
47
  size_t error_len);
34
48
  void tokamak_runtime_stop(void *runtime);
35
- bool tokamak_runtime_call(const void *runtime, const char *name,
36
- const char *body, uint64_t timeout_ms,
37
- TokamakBytes *response, char *error,
38
- size_t error_len);
49
+ bool tokamak_runtime_emit(const void *runtime, const char *name,
50
+ const char *event, uint64_t timeout_ms,
51
+ TokamakBytes *reply, char *error, size_t error_len);
52
+ bool tokamak_runtime_set_foreground(const void *runtime, bool foreground);
53
+ void tokamak_runtime_reply(const void *runtime, uint64_t id, const char *result);
39
54
 
40
55
  int32_t tokamak_runtime_server_authority(const void *runtime, const char *host,
41
56
  TokamakBytes *authority);
@@ -1,26 +1,99 @@
1
1
  import Foundation
2
2
  import TokamakRuntime
3
+ import os
4
+
5
+ #if os(iOS)
6
+ import UIKit
7
+ #endif
3
8
 
4
9
  private let startupErrorFile = "startup-error.log"
10
+ /// How long the Worker has for `resume` and `suspend`.
11
+ private let lifecycleDeadline: TimeInterval = 10
12
+ #if os(iOS)
13
+ /// The system gives an app about 30 seconds after it leaves the foreground.
14
+ private let suspendDeadline: TimeInterval = 25
15
+ #endif
5
16
 
6
17
  /// The tokamak runtime and native plugins of this process. Both outlive any
7
18
  /// WebView, so plugins receive app events before a page loads and a Worker
8
19
  /// event can run while no page is loaded.
9
- final class TokamakHost {
10
- private(set) var plugins: [any TokamakPlugin] = []
20
+ final class TokamakApp {
21
+ /// Every plugin, by ID.
22
+ private(set) var plugins: [String: any TokamakPlugin] = [:]
11
23
  private var runtime: Result<RuntimeHandle, Error>?
12
24
  private var waiting: [(Result<RuntimeHandle, Error>) -> Void] = []
25
+ /// The Worker's calls and subscriptions on the plugins.
26
+ private lazy var workerRequests = TokamakPluginRequests<UInt64>(plugins: plugins) {
27
+ [unowned self] id, result in
28
+ whenStarted { started in
29
+ if case .success(let runtime) = started {
30
+ runtime.reply(id, result: result)
31
+ }
32
+ }
33
+ }
34
+ /// Delivers `resume` and `suspend` one at a time, in order.
35
+ private let lifecycle = DispatchQueue(label: "tokamak.lifecycle")
36
+ #if os(iOS)
37
+ /// The scenes in the foreground, which plugins read on any thread.
38
+ private let foregroundScenes = OSAllocatedUnfairLock(initialState: 0)
39
+ #endif
13
40
 
14
- /// Creates the plugins and starts the runtime in the background. Call it
15
- /// before launch finishes.
41
+ /// Creates the plugins. Call it before launch finishes.
16
42
  init() {
17
- plugins = tokamakPlugins(host: self)
43
+ for plugin in tokamakPlugins(app: self) {
44
+ precondition(plugins.updateValue(plugin, forKey: plugin.id) == nil, "Duplicate plugin ID")
45
+ }
46
+ }
47
+
48
+ /// Starts the runtime in the background, which delivers `start` with
49
+ /// `foreground`. Call it once, on the main thread.
50
+ func start(foreground: Bool) {
51
+ dispatchPrecondition(condition: .onQueue(.main))
52
+ let plugins = workerPlugins
18
53
  DispatchQueue.global(qos: .userInitiated).async {
19
- let result = Result { try RuntimeHandle() }
54
+ let result = Result { try RuntimeHandle(foreground: foreground, plugins: plugins) }
20
55
  DispatchQueue.main.async { self.started(result) }
21
56
  }
22
57
  }
23
58
 
59
+ /// The plugins as the runtime calls them, on its own threads. The app
60
+ /// outlives the runtime, which holds it unretained.
61
+ private var workerPlugins: TokamakPluginHandler {
62
+ TokamakPluginHandler(
63
+ context: Unmanaged.passUnretained(self).toOpaque(),
64
+ call: { context, id, plugin, method, arguments in
65
+ TokamakApp.receive(context, id, subscribe: false, plugin, method, arguments)
66
+ },
67
+ subscribe: { context, id, plugin, method, arguments in
68
+ TokamakApp.receive(context, id, subscribe: true, plugin, method, arguments)
69
+ },
70
+ unsubscribe: { context, id in
71
+ let app = Unmanaged<TokamakApp>.fromOpaque(context!).takeUnretainedValue()
72
+ DispatchQueue.main.async { app.workerRequests.cancel(id) }
73
+ }
74
+ )
75
+ }
76
+
77
+ /// Runs a call or subscription the Worker made through `context`'s app.
78
+ private static func receive(
79
+ _ context: UnsafeMutableRawPointer?,
80
+ _ id: UInt64,
81
+ subscribe: Bool,
82
+ _ plugin: UnsafePointer<CChar>?,
83
+ _ method: UnsafePointer<CChar>?,
84
+ _ arguments: UnsafePointer<CChar>?
85
+ ) {
86
+ let app = Unmanaged<TokamakApp>.fromOpaque(context!).takeUnretainedValue()
87
+ let (plugin, method) = (String(cString: plugin!), String(cString: method!))
88
+ let arguments =
89
+ (try? JSONSerialization.jsonObject(
90
+ with: Data(String(cString: arguments!).utf8), options: .fragmentsAllowed)) ?? NSNull()
91
+ DispatchQueue.main.async {
92
+ app.workerRequests.run(
93
+ id, subscribe: subscribe, plugin: plugin, method: method, arguments: arguments)
94
+ }
95
+ }
96
+
24
97
  /// Calls `completion` on the main thread once the runtime has started or
25
98
  /// failed to start.
26
99
  func whenStarted(_ completion: @escaping (Result<RuntimeHandle, Error>) -> Void) {
@@ -32,15 +105,17 @@ final class TokamakHost {
32
105
  }
33
106
  }
34
107
 
35
- /// Posts `body`, JSON-serialisable, to the Worker's `/tokamak/<name>`
36
- /// endpoint and returns the response body, retrying a failed post. Fails
37
- /// unless the Worker responds 200 within `timeout`, which includes runtime
38
- /// startup. Call it on the main thread; `completion` runs on the main thread.
39
- func call(
108
+ /// Delivers the event `name` with `event`, JSON-serialisable, to the
109
+ /// Worker's listeners on `queue`, and calls `completion` on the main thread
110
+ /// with their reply, parsed from JSON, or nil. Fails unless they return
111
+ /// within `timeout`, which includes runtime startup. Call it on the main
112
+ /// thread.
113
+ func emit(
40
114
  _ name: String,
41
- body: Any,
115
+ event: Any,
42
116
  timeout: TimeInterval,
43
- completion: @escaping (Result<Data, Error>) -> Void
117
+ on queue: DispatchQueue = .global(qos: .userInitiated),
118
+ completion: @escaping (Result<Any?, Error>) -> Void
44
119
  ) {
45
120
  let deadline = Date() + timeout
46
121
  whenStarted { result in
@@ -48,9 +123,9 @@ final class TokamakHost {
48
123
  case .failure(let error):
49
124
  completion(.failure(error))
50
125
  case .success(let runtime):
51
- DispatchQueue.global(qos: .userInitiated).async {
126
+ queue.async {
52
127
  let outcome = Result {
53
- try runtime.call(name, body: body, timeout: deadline.timeIntervalSinceNow)
128
+ try runtime.emit(name, event: event, timeout: deadline.timeIntervalSinceNow)
54
129
  }
55
130
  DispatchQueue.main.async { completion(outcome) }
56
131
  }
@@ -58,14 +133,80 @@ final class TokamakHost {
58
133
  }
59
134
  }
60
135
 
136
+ /// Records the app moving into or out of the foreground once the runtime
137
+ /// has started, and delivers `resume` or `suspend` when that changed its
138
+ /// stage. Call it on the main thread.
139
+ func moved(toForeground foreground: Bool) {
140
+ let name = foreground ? "resume" : "suspend"
141
+ #if os(iOS)
142
+ // The system suspends the app soon after it leaves the foreground
143
+ // unless a background task holds it there.
144
+ var task = UIBackgroundTaskIdentifier.invalid
145
+ let finish = {
146
+ guard task != .invalid else { return }
147
+ UIApplication.shared.endBackgroundTask(task)
148
+ task = .invalid
149
+ }
150
+ if !foreground {
151
+ task = UIApplication.shared.beginBackgroundTask(
152
+ withName: "tokamak suspend", expirationHandler: finish)
153
+ }
154
+ let timeout = foreground ? lifecycleDeadline : suspendDeadline
155
+ #else
156
+ let finish = {}
157
+ let timeout = lifecycleDeadline
158
+ #endif
159
+ whenStarted { started in
160
+ guard case .success(let runtime) = started, runtime.setForeground(foreground) else {
161
+ finish()
162
+ return
163
+ }
164
+ self.emit(name, event: [String: Any](), timeout: timeout, on: self.lifecycle) { result in
165
+ if case .failure(let error) = result {
166
+ print("tokamak \(name) failed: \(error)")
167
+ }
168
+ finish()
169
+ }
170
+ }
171
+ }
172
+
173
+ #if os(iOS)
174
+ /// Delivers `resume` as the first scene enters the foreground.
175
+ func sceneWillEnterForeground() {
176
+ let first = foregroundScenes.withLock { scenes in
177
+ scenes += 1
178
+ return scenes == 1
179
+ }
180
+ if first {
181
+ moved(toForeground: true)
182
+ }
183
+ }
184
+
185
+ /// Delivers `suspend` as the last scene leaves the foreground.
186
+ func sceneDidEnterBackground() {
187
+ let last = foregroundScenes.withLock { scenes in
188
+ scenes -= 1
189
+ return scenes == 0
190
+ }
191
+ if last {
192
+ moved(toForeground: false)
193
+ }
194
+ }
195
+
196
+ /// Whether a scene is in the foreground. Read it on any thread.
197
+ var isInForeground: Bool {
198
+ foregroundScenes.withLock { $0 > 0 }
199
+ }
200
+ #endif
201
+
61
202
  func didRegisterForRemoteNotifications(deviceToken: Data) {
62
- for plugin in plugins {
203
+ for plugin in plugins.values {
63
204
  plugin.didRegisterForRemoteNotifications(deviceToken: deviceToken)
64
205
  }
65
206
  }
66
207
 
67
208
  func didFailToRegisterForRemoteNotifications(error: Error) {
68
- for plugin in plugins {
209
+ for plugin in plugins.values {
69
210
  plugin.didFailToRegisterForRemoteNotifications(error: error)
70
211
  }
71
212
  }
@@ -78,7 +219,7 @@ final class TokamakHost {
78
219
  ) {
79
220
  let group = DispatchGroup()
80
221
  var results: [TokamakBackgroundResult] = []
81
- for plugin in plugins {
222
+ for plugin in plugins.values {
82
223
  group.enter()
83
224
  plugin.didReceiveRemoteNotification(userInfo) { result in
84
225
  DispatchQueue.main.async {
@@ -147,7 +288,9 @@ final class RuntimeHandle {
147
288
  let appHost: String
148
289
  private var handle: UnsafeMutableRawPointer?
149
290
 
150
- init() throws {
291
+ /// Starts the runtime, which delivers `start` with `foreground`, and whose
292
+ /// Worker calls `plugins`.
293
+ init(foreground: Bool, plugins: TokamakPluginHandler) throws {
151
294
  guard
152
295
  let appHost = Bundle.main.object(forInfoDictionaryKey: "TokamakHost") as? String,
153
296
  !appHost.isEmpty
@@ -172,6 +315,8 @@ final class RuntimeHandle {
172
315
  appHost,
173
316
  endpoint,
174
317
  sessionToken,
318
+ foreground,
319
+ plugins,
175
320
  error.baseAddress,
176
321
  error.count
177
322
  )
@@ -196,6 +341,8 @@ final class RuntimeHandle {
196
341
  statePath,
197
342
  storagePath,
198
343
  appHost,
344
+ foreground,
345
+ plugins,
199
346
  error.baseAddress,
200
347
  error.count
201
348
  )
@@ -232,38 +379,54 @@ final class RuntimeHandle {
232
379
  return port
233
380
  }
234
381
 
235
- /// Posts `body` to the Worker's `/tokamak/<name>` endpoint, retrying a
236
- /// failed post, and blocks until it responds 200 or `timeout` passes.
237
- func call(_ name: String, body: Any, timeout: TimeInterval) throws -> Data {
238
- guard JSONSerialization.isValidJSONObject([body]) else {
239
- throw RuntimeError.runtime("the \(name) call body is not JSON")
382
+ /// Delivers the event `name` with `event`, JSON-serialisable, to the
383
+ /// Worker's listeners, blocking until they return or `timeout` passes, and
384
+ /// returns their reply, parsed from JSON, or nil.
385
+ func emit(_ name: String, event: Any, timeout: TimeInterval) throws -> Any? {
386
+ guard JSONSerialization.isValidJSONObject([event]) else {
387
+ throw RuntimeError.runtime("the \(name) event is not JSON")
240
388
  }
241
- let body = String(
242
- decoding: try JSONSerialization.data(withJSONObject: body, options: .fragmentsAllowed),
389
+ let event = String(
390
+ decoding: try JSONSerialization.data(withJSONObject: event, options: .fragmentsAllowed),
243
391
  as: UTF8.self
244
392
  )
245
- var response = TokamakBytes()
393
+ var reply = TokamakBytes()
246
394
  var error = [CChar](repeating: 0, count: 1024)
247
395
  let succeeded = name.withCString { name in
248
- body.withCString { body in
396
+ event.withCString { event in
249
397
  error.withUnsafeMutableBufferPointer { error in
250
- tokamak_runtime_call(
398
+ tokamak_runtime_emit(
251
399
  handle,
252
400
  name,
253
- body,
401
+ event,
254
402
  UInt64(max(timeout, 0) * 1000),
255
- &response,
403
+ &reply,
256
404
  error.baseAddress,
257
405
  error.count
258
406
  )
259
407
  }
260
408
  }
261
409
  }
262
- defer { tokamak_bytes_free(response) }
263
- guard succeeded else {
410
+ defer { tokamak_bytes_free(reply) }
411
+ guard succeeded, let json = reply.contents else {
264
412
  throw RuntimeError.runtime(String(cString: error))
265
413
  }
266
- return response.contents ?? Data()
414
+ let value = try JSONSerialization.jsonObject(with: json, options: .fragmentsAllowed)
415
+ return value is NSNull ? nil : value
416
+ }
417
+
418
+ /// Records whether the app is in the foreground, which the Worker's
419
+ /// `getLifecycleStage()` reports from then on, and returns whether that
420
+ /// changed.
421
+ func setForeground(_ foreground: Bool) -> Bool {
422
+ tokamak_runtime_set_foreground(handle, foreground)
423
+ }
424
+
425
+ /// Passes a plugin's `result`, JSON-serialisable, to the Worker's call or
426
+ /// subscription `id`.
427
+ func reply(_ id: UInt64, result: [String: Any]) {
428
+ guard let json = try? JSONSerialization.data(withJSONObject: result) else { return }
429
+ String(decoding: json, as: UTF8.self).withCString { tokamak_runtime_reply(handle, id, $0) }
267
430
  }
268
431
 
269
432
  func serverAuthority(host: String) -> AuthenticationMaterial<Data> {
@@ -8,11 +8,52 @@ struct TokamakPluginError: Error {
8
8
  static func notSupported(_ message: String) -> Self {
9
9
  Self(name: "NotSupportedError", message: message)
10
10
  }
11
+
12
+ /// UI was needed while the app cannot show it.
13
+ static let needsUI = Self(
14
+ name: "NeedsUIError",
15
+ message: "The app cannot show UI while it is in the background"
16
+ )
11
17
  }
12
18
 
13
19
  typealias TokamakPluginReply = (Result<Any?, TokamakPluginError>) -> Void
14
20
 
15
- /// A native plugin, created once per process when the app launches.
21
+ /// The app as one plugin sees it.
22
+ final class TokamakHost {
23
+ private unowned let app: TokamakApp
24
+ private let plugin: String
25
+
26
+ init(app: TokamakApp, plugin: String) {
27
+ self.app = app
28
+ self.plugin = plugin
29
+ }
30
+
31
+ /// Throws `NeedsUIError` unless the app can show UI now: on iOS, while a
32
+ /// scene is in the foreground. A hidden or minimised macOS app still shows
33
+ /// system prompts. Call it where the plugin would show UI, on any thread.
34
+ func requireUI() throws(TokamakPluginError) {
35
+ #if os(iOS)
36
+ guard app.isInForeground else { throw .needsUI }
37
+ #endif
38
+ }
39
+
40
+ /// Delivers the plugin's event `name`, which the Worker's listeners
41
+ /// receive as `<plugin>.<name>`, with `event`, JSON-serialisable, and calls
42
+ /// `completion` on the main thread with their reply, parsed from JSON, or
43
+ /// nil. Fails unless they return within `timeout`, which includes runtime
44
+ /// startup. Call it on the main thread.
45
+ func emit(
46
+ _ name: String,
47
+ event: Any,
48
+ timeout: TimeInterval,
49
+ completion: @escaping (Result<Any?, Error>) -> Void
50
+ ) {
51
+ app.emit("\(plugin).\(name)", event: event, timeout: timeout, completion: completion)
52
+ }
53
+ }
54
+
55
+ /// A native plugin, created once per process when the app launches. The page
56
+ /// and the Worker call it on the main thread.
16
57
  protocol TokamakPlugin: AnyObject {
17
58
  var id: String { get }
18
59
 
@@ -24,11 +65,13 @@ protocol TokamakPlugin: AnyObject {
24
65
  reply: @escaping TokamakPluginReply
25
66
  )
26
67
 
68
+ /// Replies with each value until the returned function is called. Throws
69
+ /// when the subscription cannot start, which ends it.
27
70
  func subscribe(
28
71
  method: String,
29
72
  arguments: Any,
30
73
  reply: @escaping TokamakPluginReply
31
- ) -> (() -> Void)
74
+ ) throws(TokamakPluginError) -> (() -> Void)
32
75
 
33
76
  func didRegisterForRemoteNotifications(deviceToken: Data)
34
77
 
@@ -55,9 +98,8 @@ extension TokamakPlugin {
55
98
  method: String,
56
99
  arguments: Any,
57
100
  reply: @escaping TokamakPluginReply
58
- ) -> (() -> Void) {
59
- reply(.failure(.notSupported("\(id).\(method) is not supported")))
60
- return {}
101
+ ) throws(TokamakPluginError) -> (() -> Void) {
102
+ throw .notSupported("\(id).\(method) is not supported")
61
103
  }
62
104
 
63
105
  func didRegisterForRemoteNotifications(deviceToken: Data) {}
@@ -72,6 +114,82 @@ extension TokamakPlugin {
72
114
  }
73
115
  }
74
116
 
117
+ /// Runs one caller's calls and subscriptions, the page's or the Worker's, on
118
+ /// the app's plugins, keyed by `Key`. `send` receives each result on the main
119
+ /// thread, as the JSON object the page's native transport receives without its
120
+ /// session and ID. Use it on the main thread.
121
+ final class TokamakPluginRequests<Key: Hashable> {
122
+ private let plugins: [String: any TokamakPlugin]
123
+ private let send: (Key, [String: Any]) -> Void
124
+ private var cancellations: [Key: () -> Void] = [:]
125
+
126
+ init(plugins: [String: any TokamakPlugin], send: @escaping (Key, [String: Any]) -> Void) {
127
+ self.plugins = plugins
128
+ self.send = send
129
+ }
130
+
131
+ /// Calls `method` of `plugin`, or subscribes to it when `subscribe`.
132
+ func run(_ key: Key, subscribe: Bool, plugin: String, method: String, arguments: Any) {
133
+ guard let target = plugins[plugin], !method.isEmpty else {
134
+ fail(key, .notSupported("Plugin is not supported"))
135
+ return
136
+ }
137
+ // Plugins reply from any thread.
138
+ let reply: TokamakPluginReply = { [weak self] result in
139
+ DispatchQueue.main.async {
140
+ self?.send(key, Self.result(result, done: !subscribe))
141
+ }
142
+ }
143
+ guard subscribe else {
144
+ target.call(method: method, arguments: arguments, reply: reply)
145
+ return
146
+ }
147
+ do {
148
+ cancellations[key] = try target.subscribe(method: method, arguments: arguments, reply: reply)
149
+ } catch {
150
+ fail(key, error)
151
+ }
152
+ }
153
+
154
+ /// Ends the request `key` with `error`.
155
+ func fail(_ key: Key, _ error: TokamakPluginError) {
156
+ send(key, Self.result(.failure(error), done: true))
157
+ }
158
+
159
+ /// Ends the subscription `key`.
160
+ func cancel(_ key: Key) {
161
+ cancellations.removeValue(forKey: key)?()
162
+ }
163
+
164
+ /// Ends every subscription.
165
+ func cancelAll() {
166
+ let cancelAll = Array(cancellations.values)
167
+ cancellations.removeAll()
168
+ for cancel in cancelAll {
169
+ cancel()
170
+ }
171
+ }
172
+
173
+ /// `result` as JSON, or the failure to make it JSON.
174
+ private static func result(
175
+ _ result: Result<Any?, TokamakPluginError>,
176
+ done: Bool
177
+ ) -> [String: Any] {
178
+ switch result {
179
+ case .success(let value):
180
+ let json: [String: Any] = ["value": value ?? NSNull(), "done": done]
181
+ if JSONSerialization.isValidJSONObject(json) {
182
+ return json
183
+ }
184
+ let error = TokamakPluginError(
185
+ name: "OperationError", message: "The plugin's value is not JSON")
186
+ return Self.result(.failure(error), done: done)
187
+ case .failure(let error):
188
+ return ["error": ["name": error.name, "message": error.message], "done": done]
189
+ }
190
+ }
191
+ }
192
+
75
193
  final class TokamakPluginBridge: NSObject, WKScriptMessageHandler {
76
194
  private struct RequestKey: Hashable {
77
195
  let session: String
@@ -80,17 +198,15 @@ final class TokamakPluginBridge: NSObject, WKScriptMessageHandler {
80
198
 
81
199
  private let host: String
82
200
  private let plugins: [String: any TokamakPlugin]
83
- private var cancellations: [RequestKey: () -> Void] = [:]
201
+ private lazy var requests = TokamakPluginRequests<RequestKey>(plugins: plugins) {
202
+ [weak self] key, result in
203
+ self?.deliver(key: key, result: result)
204
+ }
84
205
  weak var webView: WKWebView?
85
206
 
86
- init(host: String, plugins: [any TokamakPlugin]) {
207
+ init(host: String, plugins: [String: any TokamakPlugin]) {
87
208
  self.host = host
88
- var pluginsById: [String: any TokamakPlugin] = [:]
89
- for plugin in plugins {
90
- precondition(pluginsById[plugin.id] == nil, "Duplicate plugin ID")
91
- pluginsById[plugin.id] = plugin
92
- }
93
- self.plugins = pluginsById
209
+ self.plugins = plugins
94
210
  }
95
211
 
96
212
  func install(in controller: WKUserContentController) {
@@ -105,11 +221,7 @@ final class TokamakPluginBridge: NSObject, WKScriptMessageHandler {
105
221
  }
106
222
 
107
223
  func close() {
108
- let cancelAll = Array(cancellations.values)
109
- cancellations.removeAll()
110
- for cancel in cancelAll {
111
- cancel()
112
- }
224
+ requests.cancelAll()
113
225
  }
114
226
 
115
227
  func userContentController(
@@ -134,66 +246,27 @@ final class TokamakPluginBridge: NSObject, WKScriptMessageHandler {
134
246
  }
135
247
  guard let id = request["id"] as? Int else { return }
136
248
  let key = RequestKey(session: session, id: id)
137
- if type == "cancel" {
138
- cancellations.removeValue(forKey: key)?()
139
- return
140
- }
141
- dispatch(type: type, key: key, request: request)
142
- }
143
-
144
- private func dispatch(type: String, key: RequestKey, request: [String: Any]) {
145
- guard
146
- let pluginId = request["plugin"] as? String,
147
- let method = request["method"] as? String,
148
- let plugin = plugins[pluginId]
149
- else {
150
- send(key: key, result: .failure(.notSupported("Plugin is not supported")), done: true)
151
- return
152
- }
153
- let arguments = request["arguments"] ?? NSNull()
154
- let reply: TokamakPluginReply = { [weak self] result in
155
- self?.send(key: key, result: result, done: type == "call")
156
- }
157
249
  switch type {
158
- case "call":
159
- plugin.call(method: method, arguments: arguments, reply: reply)
160
- case "subscribe":
161
- let cancellation = plugin.subscribe(
162
- method: method,
163
- arguments: arguments,
164
- reply: reply
250
+ case "cancel":
251
+ requests.cancel(key)
252
+ case "call", "subscribe":
253
+ requests.run(
254
+ key,
255
+ subscribe: type == "subscribe",
256
+ plugin: request["plugin"] as? String ?? "",
257
+ method: request["method"] as? String ?? "",
258
+ arguments: request["arguments"] ?? NSNull()
165
259
  )
166
- cancellations[key] = cancellation
167
260
  default:
168
- send(
169
- key: key, result: .failure(.notSupported("Plugin operation is not supported")), done: true)
261
+ requests.fail(key, .notSupported("Plugin operation is not supported"))
170
262
  }
171
263
  }
172
264
 
173
- /// Plugins reply from any thread; delivery happens on the main thread.
174
- private func send(key: RequestKey, result: Result<Any?, TokamakPluginError>, done: Bool) {
175
- DispatchQueue.main.async { [weak self] in
176
- self?.deliver(key: key, result: result, done: done)
177
- }
178
- }
179
-
180
- private func deliver(key: RequestKey, result: Result<Any?, TokamakPluginError>, done: Bool) {
181
- if done {
182
- cancellations.removeValue(forKey: key)?()
183
- }
184
- var response: [String: Any] = [
185
- "session": key.session,
186
- "id": key.id,
187
- "done": done,
188
- ]
189
- switch result {
190
- case .success(let value):
191
- response["value"] = value ?? NSNull()
192
- case .failure(let error):
193
- response["error"] = ["name": error.name, "message": error.message]
194
- }
265
+ private func deliver(key: RequestKey, result: [String: Any]) {
266
+ var response = result
267
+ response["session"] = key.session
268
+ response["id"] = key.id
195
269
  guard
196
- JSONSerialization.isValidJSONObject(response),
197
270
  let data = try? JSONSerialization.data(withJSONObject: response),
198
271
  let json = String(data: data, encoding: .utf8),
199
272
  webView?.url?.isAppOrigin(host) == true
@@ -219,7 +219,7 @@ private final class NavigationDelegate: NSObject, WKNavigationDelegate, WKUIDele
219
219
  }
220
220
 
221
221
  private final class TokamakController {
222
- private let host: TokamakHost
222
+ private let app: TokamakApp
223
223
  private var runtime: RuntimeHandle?
224
224
  private var navigation: NavigationDelegate?
225
225
  private var pluginBridge: TokamakPluginBridge?
@@ -227,8 +227,8 @@ private final class TokamakController {
227
227
  private var dataStore: WKWebsiteDataStore?
228
228
  private var proxyPort: UInt16?
229
229
 
230
- init(host: TokamakHost) {
231
- self.host = host
230
+ init(app: TokamakApp) {
231
+ self.app = app
232
232
  }
233
233
 
234
234
  deinit {
@@ -236,7 +236,7 @@ private final class TokamakController {
236
236
  }
237
237
 
238
238
  func start(frame: CGRect, completion: @escaping (WKWebView) -> Void) {
239
- host.whenStarted { result in
239
+ app.whenStarted { result in
240
240
  switch result {
241
241
  case .success(let runtime):
242
242
  self.runtime = runtime
@@ -274,7 +274,7 @@ private final class TokamakController {
274
274
  #endif
275
275
  configuration.mediaTypesRequiringUserActionForPlayback = []
276
276
 
277
- let pluginBridge = TokamakPluginBridge(host: runtime.appHost, plugins: host.plugins)
277
+ let pluginBridge = TokamakPluginBridge(host: runtime.appHost, plugins: app.plugins)
278
278
  pluginBridge.install(in: configuration.userContentController)
279
279
  let navigation = NavigationDelegate(runtime: runtime, pluginBridge: pluginBridge)
280
280
  self.navigation = navigation
@@ -310,9 +310,11 @@ private final class TokamakController {
310
310
 
311
311
  #if os(macOS)
312
312
  private final class TokamakMacApplicationDelegate: NSObject, NSApplicationDelegate {
313
- private lazy var host = TokamakHost()
314
- private lazy var controller = TokamakController(host: host)
313
+ private lazy var app = TokamakApp()
314
+ private lazy var controller = TokamakController(app: app)
315
315
  private var window: NSWindow?
316
+ /// Whether the app is shown and its window is not miniaturised.
317
+ private var visible = false
316
318
 
317
319
  func applicationDidFinishLaunching(_ notification: Notification) {
318
320
  let window = NSWindow(
@@ -327,6 +329,9 @@ private final class TokamakController {
327
329
  window.makeKeyAndOrderFront(nil)
328
330
  NSApplication.shared.activate(ignoringOtherApps: true)
329
331
  self.window = window
332
+ visible = !NSApplication.shared.isHidden
333
+ app.start(foreground: visible)
334
+ observeVisibility()
330
335
 
331
336
  guard let content = window.contentView else { return }
332
337
  controller.start(frame: content.bounds) { webView in
@@ -343,21 +348,45 @@ private final class TokamakController {
343
348
  _ application: NSApplication,
344
349
  didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
345
350
  ) {
346
- host.didRegisterForRemoteNotifications(deviceToken: deviceToken)
351
+ app.didRegisterForRemoteNotifications(deviceToken: deviceToken)
347
352
  }
348
353
 
349
354
  func application(
350
355
  _ application: NSApplication,
351
356
  didFailToRegisterForRemoteNotificationsWithError error: Error
352
357
  ) {
353
- host.didFailToRegisterForRemoteNotifications(error: error)
358
+ app.didFailToRegisterForRemoteNotifications(error: error)
354
359
  }
355
360
 
356
361
  func application(
357
362
  _ application: NSApplication,
358
363
  didReceiveRemoteNotification userInfo: [String: Any]
359
364
  ) {
360
- host.didReceiveRemoteNotification(userInfo) { _ in }
365
+ app.didReceiveRemoteNotification(userInfo) { _ in }
366
+ }
367
+
368
+ /// Delivers `resume` and `suspend` as the app is hidden or shown, and as
369
+ /// its window is miniaturised or restored.
370
+ private func observeVisibility() {
371
+ let changes = [
372
+ NSApplication.didHideNotification,
373
+ NSApplication.didUnhideNotification,
374
+ NSWindow.didMiniaturizeNotification,
375
+ NSWindow.didDeminiaturizeNotification,
376
+ ]
377
+ for change in changes {
378
+ _ = NotificationCenter.default.addObserver(forName: change, object: nil, queue: .main) {
379
+ [weak self] _ in
380
+ self?.visibilityChanged()
381
+ }
382
+ }
383
+ }
384
+
385
+ private func visibilityChanged() {
386
+ let visible = !NSApplication.shared.isHidden && window?.isMiniaturized == false
387
+ guard visible != self.visible else { return }
388
+ self.visible = visible
389
+ app.moved(toForeground: visible)
361
390
  }
362
391
  }
363
392
 
@@ -393,20 +422,32 @@ private final class TokamakController {
393
422
 
394
423
  private class TokamakIOSApplicationDelegate: UIResponder, UIApplicationDelegate {
395
424
  /// Exists from launch, whether or not a scene connects.
396
- let host = TokamakHost()
425
+ let app = TokamakApp()
426
+
427
+ /// Starts the runtime once launch finishes. A scene a user launches the
428
+ /// app into has entered the foreground by then.
429
+ func application(
430
+ _ application: UIApplication,
431
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
432
+ ) -> Bool {
433
+ DispatchQueue.main.async {
434
+ self.app.start(foreground: self.app.isInForeground)
435
+ }
436
+ return true
437
+ }
397
438
 
398
439
  func application(
399
440
  _ application: UIApplication,
400
441
  didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
401
442
  ) {
402
- host.didRegisterForRemoteNotifications(deviceToken: deviceToken)
443
+ app.didRegisterForRemoteNotifications(deviceToken: deviceToken)
403
444
  }
404
445
 
405
446
  func application(
406
447
  _ application: UIApplication,
407
448
  didFailToRegisterForRemoteNotificationsWithError error: Error
408
449
  ) {
409
- host.didFailToRegisterForRemoteNotifications(error: error)
450
+ app.didFailToRegisterForRemoteNotifications(error: error)
410
451
  }
411
452
  }
412
453
 
@@ -416,7 +457,7 @@ private final class TokamakController {
416
457
  didReceiveRemoteNotification userInfo: [AnyHashable: Any],
417
458
  fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
418
459
  ) {
419
- host.didReceiveRemoteNotification(userInfo) { result in
460
+ app.didReceiveRemoteNotification(userInfo) { result in
420
461
  switch result {
421
462
  case .newData: completionHandler(.newData)
422
463
  case .noData: completionHandler(.noData)
@@ -431,6 +472,9 @@ private final class TokamakController {
431
472
  private final class TokamakSceneDelegate: UIResponder, UIWindowSceneDelegate {
432
473
  var window: UIWindow?
433
474
  private var controller: TokamakController?
475
+ private var app: TokamakApp {
476
+ (UIApplication.shared.delegate as! TokamakIOSApplicationDelegate).app
477
+ }
434
478
 
435
479
  func scene(
436
480
  _ scene: UIScene,
@@ -438,14 +482,13 @@ private final class TokamakController {
438
482
  options connectionOptions: UIScene.ConnectionOptions
439
483
  ) {
440
484
  guard let scene = scene as? UIWindowScene else { return }
441
- let host = (UIApplication.shared.delegate as! TokamakIOSApplicationDelegate).host
442
485
  let viewController = UIViewController()
443
486
  let window = UIWindow(windowScene: scene)
444
487
  window.rootViewController = viewController
445
488
  window.makeKeyAndVisible()
446
489
  self.window = window
447
490
 
448
- let controller = TokamakController(host: host)
491
+ let controller = TokamakController(app: app)
449
492
  controller.start(frame: viewController.view.bounds) { webView in
450
493
  webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
451
494
  viewController.view.addSubview(webView)
@@ -454,9 +497,14 @@ private final class TokamakController {
454
497
  }
455
498
 
456
499
  func sceneWillEnterForeground(_ scene: UIScene) {
500
+ app.sceneWillEnterForeground()
457
501
  controller?.restoreGateway()
458
502
  }
459
503
 
504
+ func sceneDidEnterBackground(_ scene: UIScene) {
505
+ app.sceneDidEnterBackground()
506
+ }
507
+
460
508
  /// Releases the scene's window and page, which a reconnection recreates.
461
509
  func sceneDidDisconnect(_ scene: UIScene) {
462
510
  window = nil
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokamakdev/platform-ios-simulator-x64",
3
- "version": "0.1.1-beta.82",
3
+ "version": "0.1.1-beta.84",
4
4
  "description": "The tokamak platform pack for ios-simulator-x64",
5
5
  "repository": {
6
6
  "type": "git",