@tokamakdev/platform-ios-simulator-x64 0.1.1-beta.81 → 0.1.1-beta.83

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") ;;
@@ -23,19 +23,19 @@ typedef struct {
23
23
 
24
24
  void *tokamak_runtime_start(const char *packaged_dir, const char *state_dir,
25
25
  const char *storage_dir, const char *host,
26
- char *error, size_t error_len);
26
+ bool foreground, char *error, size_t error_len);
27
27
  void *tokamak_runtime_start_development(const char *state_dir, const char *host,
28
28
  const char *endpoint,
29
- const char *session_token, char *error,
29
+ const char *session_token,
30
+ bool foreground, char *error,
30
31
  size_t error_len);
31
32
  uint16_t tokamak_runtime_port(const void *runtime);
32
33
  uint16_t tokamak_runtime_restore_gateway(const void *runtime, char *error,
33
34
  size_t error_len);
34
35
  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);
36
+ bool tokamak_runtime_emit(const void *runtime, const char *name,
37
+ const char *event, uint64_t timeout_ms,
38
+ TokamakBytes *reply, char *error, size_t error_len);
39
39
 
40
40
  int32_t tokamak_runtime_server_authority(const void *runtime, const char *host,
41
41
  TokamakBytes *authority);
@@ -1,22 +1,43 @@
1
1
  import Foundation
2
2
  import TokamakRuntime
3
3
 
4
+ #if os(iOS)
5
+ import UIKit
6
+ #endif
7
+
4
8
  private let startupErrorFile = "startup-error.log"
9
+ /// How long the Worker has for `resume` and `suspend`.
10
+ private let lifecycleDeadline: TimeInterval = 10
11
+ #if os(iOS)
12
+ /// The system gives an app about 30 seconds after it leaves the foreground.
13
+ private let suspendDeadline: TimeInterval = 25
14
+ #endif
5
15
 
6
16
  /// The tokamak runtime and native plugins of this process. Both outlive any
7
17
  /// WebView, so plugins receive app events before a page loads and a Worker
8
18
  /// event can run while no page is loaded.
9
- final class TokamakHost {
19
+ final class TokamakApp {
10
20
  private(set) var plugins: [any TokamakPlugin] = []
11
21
  private var runtime: Result<RuntimeHandle, Error>?
12
22
  private var waiting: [(Result<RuntimeHandle, Error>) -> Void] = []
23
+ /// Delivers `resume` and `suspend` one at a time, in order.
24
+ private let lifecycle = DispatchQueue(label: "tokamak.lifecycle")
25
+ #if os(iOS)
26
+ /// The scenes in the foreground.
27
+ private var foregroundScenes = 0
28
+ #endif
13
29
 
14
- /// Creates the plugins and starts the runtime in the background. Call it
15
- /// before launch finishes.
30
+ /// Creates the plugins. Call it before launch finishes.
16
31
  init() {
17
- plugins = tokamakPlugins(host: self)
32
+ plugins = tokamakPlugins(app: self)
33
+ }
34
+
35
+ /// Starts the runtime in the background, which delivers `start` with
36
+ /// `foreground`. Call it once, on the main thread.
37
+ func start(foreground: Bool) {
38
+ dispatchPrecondition(condition: .onQueue(.main))
18
39
  DispatchQueue.global(qos: .userInitiated).async {
19
- let result = Result { try RuntimeHandle() }
40
+ let result = Result { try RuntimeHandle(foreground: foreground) }
20
41
  DispatchQueue.main.async { self.started(result) }
21
42
  }
22
43
  }
@@ -32,15 +53,17 @@ final class TokamakHost {
32
53
  }
33
54
  }
34
55
 
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(
56
+ /// Delivers the event `name` with `event`, JSON-serialisable, to the
57
+ /// Worker's listeners on `queue`, and calls `completion` on the main thread
58
+ /// with their reply, parsed from JSON, or nil. Fails unless they return
59
+ /// within `timeout`, which includes runtime startup. Call it on the main
60
+ /// thread.
61
+ func emit(
40
62
  _ name: String,
41
- body: Any,
63
+ event: Any,
42
64
  timeout: TimeInterval,
43
- completion: @escaping (Result<Data, Error>) -> Void
65
+ on queue: DispatchQueue = .global(qos: .userInitiated),
66
+ completion: @escaping (Result<Any?, Error>) -> Void
44
67
  ) {
45
68
  let deadline = Date() + timeout
46
69
  whenStarted { result in
@@ -48,9 +71,9 @@ final class TokamakHost {
48
71
  case .failure(let error):
49
72
  completion(.failure(error))
50
73
  case .success(let runtime):
51
- DispatchQueue.global(qos: .userInitiated).async {
74
+ queue.async {
52
75
  let outcome = Result {
53
- try runtime.call(name, body: body, timeout: deadline.timeIntervalSinceNow)
76
+ try runtime.emit(name, event: event, timeout: deadline.timeIntervalSinceNow)
54
77
  }
55
78
  DispatchQueue.main.async { completion(outcome) }
56
79
  }
@@ -58,6 +81,59 @@ final class TokamakHost {
58
81
  }
59
82
  }
60
83
 
84
+ /// Delivers `resume` when the app moves into the foreground and `suspend`
85
+ /// when it moves out. Call it on the main thread.
86
+ func moved(toForeground foreground: Bool) {
87
+ let name = foreground ? "resume" : "suspend"
88
+ #if os(iOS)
89
+ // The system suspends the app soon after it leaves the foreground
90
+ // unless a background task holds it there.
91
+ var task = UIBackgroundTaskIdentifier.invalid
92
+ let finish = {
93
+ guard task != .invalid else { return }
94
+ UIApplication.shared.endBackgroundTask(task)
95
+ task = .invalid
96
+ }
97
+ if !foreground {
98
+ task = UIApplication.shared.beginBackgroundTask(
99
+ withName: "tokamak suspend", expirationHandler: finish)
100
+ }
101
+ let timeout = foreground ? lifecycleDeadline : suspendDeadline
102
+ #else
103
+ let finish = {}
104
+ let timeout = lifecycleDeadline
105
+ #endif
106
+ emit(name, event: [String: Any](), timeout: timeout, on: lifecycle) { result in
107
+ if case .failure(let error) = result {
108
+ print("tokamak \(name) failed: \(error)")
109
+ }
110
+ finish()
111
+ }
112
+ }
113
+
114
+ #if os(iOS)
115
+ /// Delivers `resume` as the first scene enters the foreground.
116
+ func sceneWillEnterForeground() {
117
+ foregroundScenes += 1
118
+ if foregroundScenes == 1 {
119
+ moved(toForeground: true)
120
+ }
121
+ }
122
+
123
+ /// Delivers `suspend` as the last scene leaves the foreground.
124
+ func sceneDidEnterBackground() {
125
+ foregroundScenes -= 1
126
+ if foregroundScenes == 0 {
127
+ moved(toForeground: false)
128
+ }
129
+ }
130
+
131
+ /// Whether a scene is in the foreground.
132
+ var isInForeground: Bool {
133
+ foregroundScenes > 0
134
+ }
135
+ #endif
136
+
61
137
  func didRegisterForRemoteNotifications(deviceToken: Data) {
62
138
  for plugin in plugins {
63
139
  plugin.didRegisterForRemoteNotifications(deviceToken: deviceToken)
@@ -147,7 +223,8 @@ final class RuntimeHandle {
147
223
  let appHost: String
148
224
  private var handle: UnsafeMutableRawPointer?
149
225
 
150
- init() throws {
226
+ /// Starts the runtime, which delivers `start` with `foreground`.
227
+ init(foreground: Bool) throws {
151
228
  guard
152
229
  let appHost = Bundle.main.object(forInfoDictionaryKey: "TokamakHost") as? String,
153
230
  !appHost.isEmpty
@@ -172,6 +249,7 @@ final class RuntimeHandle {
172
249
  appHost,
173
250
  endpoint,
174
251
  sessionToken,
252
+ foreground,
175
253
  error.baseAddress,
176
254
  error.count
177
255
  )
@@ -196,6 +274,7 @@ final class RuntimeHandle {
196
274
  statePath,
197
275
  storagePath,
198
276
  appHost,
277
+ foreground,
199
278
  error.baseAddress,
200
279
  error.count
201
280
  )
@@ -232,38 +311,40 @@ final class RuntimeHandle {
232
311
  return port
233
312
  }
234
313
 
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")
314
+ /// Delivers the event `name` with `event`, JSON-serialisable, to the
315
+ /// Worker's listeners, blocking until they return or `timeout` passes, and
316
+ /// returns their reply, parsed from JSON, or nil.
317
+ func emit(_ name: String, event: Any, timeout: TimeInterval) throws -> Any? {
318
+ guard JSONSerialization.isValidJSONObject([event]) else {
319
+ throw RuntimeError.runtime("the \(name) event is not JSON")
240
320
  }
241
- let body = String(
242
- decoding: try JSONSerialization.data(withJSONObject: body, options: .fragmentsAllowed),
321
+ let event = String(
322
+ decoding: try JSONSerialization.data(withJSONObject: event, options: .fragmentsAllowed),
243
323
  as: UTF8.self
244
324
  )
245
- var response = TokamakBytes()
325
+ var reply = TokamakBytes()
246
326
  var error = [CChar](repeating: 0, count: 1024)
247
327
  let succeeded = name.withCString { name in
248
- body.withCString { body in
328
+ event.withCString { event in
249
329
  error.withUnsafeMutableBufferPointer { error in
250
- tokamak_runtime_call(
330
+ tokamak_runtime_emit(
251
331
  handle,
252
332
  name,
253
- body,
333
+ event,
254
334
  UInt64(max(timeout, 0) * 1000),
255
- &response,
335
+ &reply,
256
336
  error.baseAddress,
257
337
  error.count
258
338
  )
259
339
  }
260
340
  }
261
341
  }
262
- defer { tokamak_bytes_free(response) }
263
- guard succeeded else {
342
+ defer { tokamak_bytes_free(reply) }
343
+ guard succeeded, let json = reply.contents else {
264
344
  throw RuntimeError.runtime(String(cString: error))
265
345
  }
266
- return response.contents ?? Data()
346
+ let value = try JSONSerialization.jsonObject(with: json, options: .fragmentsAllowed)
347
+ return value is NSNull ? nil : value
267
348
  }
268
349
 
269
350
  func serverAuthority(host: String) -> AuthenticationMaterial<Data> {
@@ -12,6 +12,31 @@ struct TokamakPluginError: Error {
12
12
 
13
13
  typealias TokamakPluginReply = (Result<Any?, TokamakPluginError>) -> Void
14
14
 
15
+ /// The app as one plugin sees it.
16
+ final class TokamakHost {
17
+ private unowned let app: TokamakApp
18
+ private let plugin: String
19
+
20
+ init(app: TokamakApp, plugin: String) {
21
+ self.app = app
22
+ self.plugin = plugin
23
+ }
24
+
25
+ /// Delivers the plugin's event `name`, which the Worker's listeners
26
+ /// receive as `<plugin>.<name>`, with `event`, JSON-serialisable, and calls
27
+ /// `completion` on the main thread with their reply, parsed from JSON, or
28
+ /// nil. Fails unless they return within `timeout`, which includes runtime
29
+ /// startup. Call it on the main thread.
30
+ func emit(
31
+ _ name: String,
32
+ event: Any,
33
+ timeout: TimeInterval,
34
+ completion: @escaping (Result<Any?, Error>) -> Void
35
+ ) {
36
+ app.emit("\(plugin).\(name)", event: event, timeout: timeout, completion: completion)
37
+ }
38
+ }
39
+
15
40
  /// A native plugin, created once per process when the app launches.
16
41
  protocol TokamakPlugin: AnyObject {
17
42
  var id: String { get }
@@ -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.81",
3
+ "version": "0.1.1-beta.83",
4
4
  "description": "The tokamak platform pack for ios-simulator-x64",
5
5
  "repository": {
6
6
  "type": "git",