@tokamakdev/platform-ios-simulator-x64 0.1.1-beta.100

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.
@@ -0,0 +1,569 @@
1
+ import Foundation
2
+ import TokamakRuntime
3
+
4
+ #if os(iOS)
5
+ import UIKit
6
+ #endif
7
+
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
15
+
16
+ /// The tokamak runtime and native plugins of this process. Both outlive any
17
+ /// WebView, so plugins receive app events before a page loads and a Worker
18
+ /// event can run while no page is loaded.
19
+ final class TokamakApp {
20
+ /// Every plugin, by ID.
21
+ private(set) var plugins: [String: any TokamakPlugin] = [:]
22
+ private var runtime: Result<RuntimeHandle, Error>?
23
+ private var waiting: [(Result<RuntimeHandle, Error>) -> Void] = []
24
+ /// The Worker's calls and subscriptions on the plugins.
25
+ private lazy var workerRequests = TokamakPluginRequests<UInt64>(plugins: plugins) {
26
+ [unowned self] id, result in
27
+ whenStarted { started in
28
+ if case .success(let runtime) = started {
29
+ runtime.reply(id, result: result)
30
+ }
31
+ }
32
+ }
33
+ /// Delivers `resume` and `suspend` one at a time, in order.
34
+ private let lifecycle = DispatchQueue(label: "tokamak.lifecycle")
35
+ #if os(iOS)
36
+ /// The scenes in the foreground.
37
+ private var foregroundScenes = 0
38
+ #endif
39
+
40
+ /// Creates the plugins. Call it before launch finishes.
41
+ init() {
42
+ for plugin in tokamakPlugins(app: self) {
43
+ precondition(plugins.updateValue(plugin, forKey: plugin.id) == nil, "Duplicate plugin ID")
44
+ }
45
+ }
46
+
47
+ /// Starts the runtime in the background, which delivers `start` with
48
+ /// `foreground`. Call it once, on the main thread.
49
+ func start(foreground: Bool) {
50
+ dispatchPrecondition(condition: .onQueue(.main))
51
+ let plugins = workerPlugins
52
+ DispatchQueue.global(qos: .userInitiated).async {
53
+ let result = Result { try RuntimeHandle(foreground: foreground, plugins: plugins) }
54
+ DispatchQueue.main.async { self.started(result) }
55
+ }
56
+ }
57
+
58
+ /// The plugins as the runtime calls them, on its own threads. The app
59
+ /// outlives the runtime, which holds it unretained.
60
+ private var workerPlugins: TokamakPluginHandler {
61
+ TokamakPluginHandler(
62
+ context: Unmanaged.passUnretained(self).toOpaque(),
63
+ call: { context, id, plugin, method, arguments in
64
+ TokamakApp.receive(context, id, subscribe: false, plugin, method, arguments)
65
+ },
66
+ subscribe: { context, id, plugin, method, arguments in
67
+ TokamakApp.receive(context, id, subscribe: true, plugin, method, arguments)
68
+ },
69
+ unsubscribe: { context, id in
70
+ let app = Unmanaged<TokamakApp>.fromOpaque(context!).takeUnretainedValue()
71
+ DispatchQueue.main.async { app.workerRequests.cancel(id) }
72
+ }
73
+ )
74
+ }
75
+
76
+ /// Runs a call or subscription the Worker made through `context`'s app.
77
+ private static func receive(
78
+ _ context: UnsafeMutableRawPointer?,
79
+ _ id: UInt64,
80
+ subscribe: Bool,
81
+ _ plugin: UnsafePointer<CChar>?,
82
+ _ method: UnsafePointer<CChar>?,
83
+ _ arguments: UnsafePointer<CChar>?
84
+ ) {
85
+ let app = Unmanaged<TokamakApp>.fromOpaque(context!).takeUnretainedValue()
86
+ let (plugin, method) = (String(cString: plugin!), String(cString: method!))
87
+ let arguments =
88
+ (try? JSONSerialization.jsonObject(
89
+ with: Data(String(cString: arguments!).utf8), options: .fragmentsAllowed)) ?? NSNull()
90
+ DispatchQueue.main.async {
91
+ app.workerRequests.run(
92
+ id, subscribe: subscribe, plugin: plugin, method: method, arguments: arguments)
93
+ }
94
+ }
95
+
96
+ /// Calls `completion` on the main thread once the runtime has started or
97
+ /// failed to start.
98
+ func whenStarted(_ completion: @escaping (Result<RuntimeHandle, Error>) -> Void) {
99
+ dispatchPrecondition(condition: .onQueue(.main))
100
+ if let runtime {
101
+ completion(runtime)
102
+ } else {
103
+ waiting.append(completion)
104
+ }
105
+ }
106
+
107
+ /// Delivers the event `name` with `event`, JSON-serialisable, to the
108
+ /// Worker's listeners on `queue`, and calls `completion` on the main thread
109
+ /// with their reply, parsed from JSON, or nil. Fails unless they return
110
+ /// within `timeout`, which includes runtime startup. Call it on the main
111
+ /// thread.
112
+ func emit(
113
+ _ name: String,
114
+ event: Any,
115
+ timeout: TimeInterval,
116
+ on queue: DispatchQueue = .global(qos: .userInitiated),
117
+ completion: @escaping (Result<Any?, Error>) -> Void
118
+ ) {
119
+ let deadline = Date() + timeout
120
+ withRuntime(on: queue, completion: completion) { runtime in
121
+ try runtime.emit(name, event: event, timeout: deadline.timeIntervalSinceNow)
122
+ }
123
+ }
124
+
125
+ /// Fetches what the app serves at `path` and calls `completion` on the main
126
+ /// thread with the body. Fails unless it arrives within `timeout`, which
127
+ /// includes runtime startup. Call it on the main thread.
128
+ func fetch(
129
+ _ path: String,
130
+ timeout: TimeInterval,
131
+ completion: @escaping (Result<Data, Error>) -> Void
132
+ ) {
133
+ let deadline = Date() + timeout
134
+ withRuntime(on: .global(qos: .userInitiated), completion: completion) { runtime in
135
+ try runtime.fetch(path, timeout: deadline.timeIntervalSinceNow)
136
+ }
137
+ }
138
+
139
+ /// Runs `work` with the started runtime on `queue`, and calls `completion`
140
+ /// on the main thread with its result.
141
+ private func withRuntime<Value>(
142
+ on queue: DispatchQueue,
143
+ completion: @escaping (Result<Value, Error>) -> Void,
144
+ work: @escaping (RuntimeHandle) throws -> Value
145
+ ) {
146
+ whenStarted { result in
147
+ switch result {
148
+ case .failure(let error):
149
+ completion(.failure(error))
150
+ case .success(let runtime):
151
+ queue.async {
152
+ let outcome = Result { try work(runtime) }
153
+ DispatchQueue.main.async { completion(outcome) }
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ /// Records the app moving into or out of the foreground once the runtime
160
+ /// has started, and delivers `resume` or `suspend` when that changed its
161
+ /// stage. Call it on the main thread.
162
+ func moved(toForeground foreground: Bool) {
163
+ let name = foreground ? "resume" : "suspend"
164
+ #if os(iOS)
165
+ // The system suspends the app soon after it leaves the foreground
166
+ // unless a background task holds it there.
167
+ var task = UIBackgroundTaskIdentifier.invalid
168
+ let finish = {
169
+ guard task != .invalid else { return }
170
+ UIApplication.shared.endBackgroundTask(task)
171
+ task = .invalid
172
+ }
173
+ if !foreground {
174
+ task = UIApplication.shared.beginBackgroundTask(
175
+ withName: "tokamak suspend", expirationHandler: finish)
176
+ }
177
+ let timeout = foreground ? lifecycleDeadline : suspendDeadline
178
+ #else
179
+ let finish = {}
180
+ let timeout = lifecycleDeadline
181
+ #endif
182
+ whenStarted { started in
183
+ guard case .success(let runtime) = started, runtime.setForeground(foreground) else {
184
+ finish()
185
+ return
186
+ }
187
+ self.emit(name, event: [String: Any](), timeout: timeout, on: self.lifecycle) { result in
188
+ if case .failure(let error) = result {
189
+ print("tokamak \(name) failed: \(error)")
190
+ }
191
+ finish()
192
+ }
193
+ }
194
+ }
195
+
196
+ #if os(iOS)
197
+ /// Delivers `resume` as the first scene enters the foreground.
198
+ func sceneWillEnterForeground() {
199
+ foregroundScenes += 1
200
+ if foregroundScenes == 1 {
201
+ moved(toForeground: true)
202
+ }
203
+ }
204
+
205
+ /// Delivers `suspend` as the last scene leaves the foreground.
206
+ func sceneDidEnterBackground() {
207
+ foregroundScenes -= 1
208
+ if foregroundScenes == 0 {
209
+ moved(toForeground: false)
210
+ }
211
+ }
212
+
213
+ /// Whether a scene is in the foreground.
214
+ var hasForegroundScene: Bool {
215
+ foregroundScenes > 0
216
+ }
217
+
218
+ /// Whether the app is in the foreground, as the runtime records it, or
219
+ /// until the runtime has started, whether a scene is in the foreground.
220
+ /// Read it on the main thread.
221
+ var isInForeground: Bool {
222
+ guard case .success(let runtime) = runtime else { return hasForegroundScene }
223
+ return runtime.isForeground
224
+ }
225
+ #endif
226
+
227
+ func didRegisterForRemoteNotifications(deviceToken: Data) {
228
+ for plugin in plugins.values {
229
+ plugin.didRegisterForRemoteNotifications(deviceToken: deviceToken)
230
+ }
231
+ }
232
+
233
+ func didFailToRegisterForRemoteNotifications(error: Error) {
234
+ for plugin in plugins.values {
235
+ plugin.didFailToRegisterForRemoteNotifications(error: error)
236
+ }
237
+ }
238
+
239
+ /// Delivers a remote notification to every plugin, calling `completion` on
240
+ /// the main thread once each has finished with it.
241
+ func didReceiveRemoteNotification(
242
+ _ userInfo: [AnyHashable: Any],
243
+ completion: @escaping (TokamakBackgroundResult) -> Void
244
+ ) {
245
+ let group = DispatchGroup()
246
+ var results: [TokamakBackgroundResult] = []
247
+ for plugin in plugins.values {
248
+ group.enter()
249
+ plugin.didReceiveRemoteNotification(userInfo) { result in
250
+ DispatchQueue.main.async {
251
+ results.append(result)
252
+ group.leave()
253
+ }
254
+ }
255
+ }
256
+ group.notify(queue: .main) {
257
+ completion(TokamakBackgroundResult(combining: results))
258
+ }
259
+ }
260
+
261
+ private func started(_ result: Result<RuntimeHandle, Error>) {
262
+ if case .failure(let error) = result {
263
+ RuntimeHandle.recordStartupFailure(error)
264
+ }
265
+ runtime = result
266
+ let callbacks = waiting
267
+ waiting.removeAll()
268
+ for callback in callbacks {
269
+ callback(result)
270
+ }
271
+ }
272
+ }
273
+
274
+ /// What a plugin did with a background event, reported to the system.
275
+ enum TokamakBackgroundResult {
276
+ case newData
277
+ case noData
278
+ case failed
279
+
280
+ /// New data when any plugin fetched some, otherwise a failure when any
281
+ /// plugin failed.
282
+ init(combining results: [Self]) {
283
+ if results.contains(.newData) {
284
+ self = .newData
285
+ } else if results.contains(.failed) {
286
+ self = .failed
287
+ } else {
288
+ self = .noData
289
+ }
290
+ }
291
+ }
292
+
293
+ enum RuntimeError: Error, CustomStringConvertible {
294
+ case configuration(String)
295
+ case runtime(String)
296
+
297
+ var description: String {
298
+ switch self {
299
+ case .configuration(let message), .runtime(let message):
300
+ return message
301
+ }
302
+ }
303
+ }
304
+
305
+ enum AuthenticationMaterial<Value> {
306
+ case defaultHandling
307
+ case cancel
308
+ case use(Value)
309
+ }
310
+
311
+ /// A started tokamak runtime, stopped when released.
312
+ final class RuntimeHandle {
313
+ let appHost: String
314
+ private var handle: UnsafeMutableRawPointer?
315
+
316
+ /// Starts the runtime, which delivers `start` with `foreground`, and whose
317
+ /// Worker calls `plugins`.
318
+ init(foreground: Bool, plugins: TokamakPluginHandler) throws {
319
+ guard
320
+ let appHost = Bundle.main.object(forInfoDictionaryKey: "TokamakHost") as? String,
321
+ !appHost.isEmpty
322
+ else {
323
+ throw RuntimeError.configuration("TokamakHost is required")
324
+ }
325
+ self.appHost = appHost
326
+
327
+ let state = try Self.stateDirectory()
328
+ var error = [CChar](repeating: 0, count: 512)
329
+ if let endpoint = Bundle.main.object(forInfoDictionaryKey: "TokamakDevEndpoint") as? String,
330
+ let sessionToken = Bundle.main.object(forInfoDictionaryKey: "TokamakDevSessionToken")
331
+ as? String
332
+ {
333
+ handle = state.path.withCString { statePath in
334
+ appHost.withCString { appHost in
335
+ endpoint.withCString { endpoint in
336
+ sessionToken.withCString { sessionToken in
337
+ error.withUnsafeMutableBufferPointer { error in
338
+ tokamak_runtime_start_development(
339
+ statePath,
340
+ appHost,
341
+ endpoint,
342
+ sessionToken,
343
+ foreground,
344
+ plugins,
345
+ error.baseAddress,
346
+ error.count
347
+ )
348
+ }
349
+ }
350
+ }
351
+ }
352
+ }
353
+ } else {
354
+ let bundle = Bundle.main.resourceURL?.appendingPathComponent("app")
355
+ guard let bundle else {
356
+ throw RuntimeError.configuration("app bundle resources are unavailable")
357
+ }
358
+ let storage = try Self.storageDirectory()
359
+ handle = bundle.path.withCString { bundlePath in
360
+ state.path.withCString { statePath in
361
+ storage.path.withCString { storagePath in
362
+ appHost.withCString { appHost in
363
+ error.withUnsafeMutableBufferPointer { error in
364
+ tokamak_runtime_start(
365
+ bundlePath,
366
+ statePath,
367
+ storagePath,
368
+ appHost,
369
+ foreground,
370
+ plugins,
371
+ error.baseAddress,
372
+ error.count
373
+ )
374
+ }
375
+ }
376
+ }
377
+ }
378
+ }
379
+ }
380
+ guard handle != nil else {
381
+ throw RuntimeError.runtime(String(cString: error))
382
+ }
383
+ try? FileManager.default.removeItem(at: state.appendingPathComponent(startupErrorFile))
384
+ }
385
+
386
+ deinit {
387
+ if let handle {
388
+ tokamak_runtime_stop(handle)
389
+ }
390
+ }
391
+
392
+ var port: UInt16 {
393
+ tokamak_runtime_port(handle)
394
+ }
395
+
396
+ func restoreGateway() throws -> UInt16 {
397
+ var error = [CChar](repeating: 0, count: 512)
398
+ let port = error.withUnsafeMutableBufferPointer { error in
399
+ tokamak_runtime_restore_gateway(handle, error.baseAddress, error.count)
400
+ }
401
+ guard port != 0 else {
402
+ throw RuntimeError.runtime(String(cString: error))
403
+ }
404
+ return port
405
+ }
406
+
407
+ /// Delivers the event `name` with `event`, JSON-serialisable, to the
408
+ /// Worker's listeners, blocking until they return or `timeout` passes, and
409
+ /// returns their reply, parsed from JSON, or nil.
410
+ func emit(_ name: String, event: Any, timeout: TimeInterval) throws -> Any? {
411
+ guard JSONSerialization.isValidJSONObject([event]) else {
412
+ throw RuntimeError.runtime("the \(name) event is not JSON")
413
+ }
414
+ let event = String(
415
+ decoding: try JSONSerialization.data(withJSONObject: event, options: .fragmentsAllowed),
416
+ as: UTF8.self
417
+ )
418
+ var reply = TokamakBytes()
419
+ var error = [CChar](repeating: 0, count: 1024)
420
+ let succeeded = name.withCString { name in
421
+ event.withCString { event in
422
+ error.withUnsafeMutableBufferPointer { error in
423
+ tokamak_runtime_emit(
424
+ handle,
425
+ name,
426
+ event,
427
+ UInt64(max(timeout, 0) * 1000),
428
+ &reply,
429
+ error.baseAddress,
430
+ error.count
431
+ )
432
+ }
433
+ }
434
+ }
435
+ defer { tokamak_bytes_free(reply) }
436
+ guard succeeded, let json = reply.contents else {
437
+ throw RuntimeError.runtime(String(cString: error))
438
+ }
439
+ let value = try JSONSerialization.jsonObject(with: json, options: .fragmentsAllowed)
440
+ return value is NSNull ? nil : value
441
+ }
442
+
443
+ /// What the app serves at `path`, blocking until it arrives or `timeout`
444
+ /// passes.
445
+ func fetch(_ path: String, timeout: TimeInterval) throws -> Data {
446
+ var body = TokamakBytes()
447
+ var error = [CChar](repeating: 0, count: 1024)
448
+ let succeeded = path.withCString { path in
449
+ error.withUnsafeMutableBufferPointer { error in
450
+ tokamak_runtime_fetch(
451
+ handle,
452
+ path,
453
+ UInt64(max(timeout, 0) * 1000),
454
+ &body,
455
+ error.baseAddress,
456
+ error.count
457
+ )
458
+ }
459
+ }
460
+ defer { tokamak_bytes_free(body) }
461
+ guard succeeded else {
462
+ throw RuntimeError.runtime(String(cString: error))
463
+ }
464
+ return body.contents ?? Data()
465
+ }
466
+
467
+ /// Records whether the app is in the foreground, which the Worker's
468
+ /// `getLifecycleStage()` reports from then on, and returns whether that
469
+ /// changed.
470
+ func setForeground(_ foreground: Bool) -> Bool {
471
+ tokamak_runtime_set_foreground(handle, foreground)
472
+ }
473
+
474
+ /// Whether the app is in the foreground, as last recorded.
475
+ var isForeground: Bool {
476
+ tokamak_runtime_is_foreground(handle)
477
+ }
478
+
479
+ /// Passes a plugin's `result`, JSON-serialisable, to the Worker's call or
480
+ /// subscription `id`.
481
+ func reply(_ id: UInt64, result: [String: Any]) {
482
+ guard let json = try? JSONSerialization.data(withJSONObject: result) else { return }
483
+ String(decoding: json, as: UTF8.self).withCString { tokamak_runtime_reply(handle, id, $0) }
484
+ }
485
+
486
+ func serverAuthority(host: String) -> AuthenticationMaterial<Data> {
487
+ var bytes = TokamakBytes()
488
+ let decision = host.withCString {
489
+ tokamak_runtime_server_authority(handle, $0, &bytes)
490
+ }
491
+ switch decision {
492
+ case Int32(TOKAMAK_DECISION_DEFAULT):
493
+ return .defaultHandling
494
+ case Int32(TOKAMAK_DECISION_USE):
495
+ defer { tokamak_bytes_free(bytes) }
496
+ guard let authority = bytes.contents else { return .cancel }
497
+ return .use(authority)
498
+ default:
499
+ return .cancel
500
+ }
501
+ }
502
+
503
+ func clientIdentity(host: String, previousFailures: Int) -> AuthenticationMaterial<(Data, Data)> {
504
+ var identity = TokamakIdentity()
505
+ let decision = host.withCString {
506
+ tokamak_runtime_client_identity(handle, $0, max(previousFailures, 0), &identity)
507
+ }
508
+ switch decision {
509
+ case Int32(TOKAMAK_DECISION_DEFAULT):
510
+ return .defaultHandling
511
+ case Int32(TOKAMAK_DECISION_USE):
512
+ defer { tokamak_identity_free(identity) }
513
+ guard
514
+ let certificate = identity.certificate.contents,
515
+ let privateKey = identity.private_key.contents
516
+ else {
517
+ return .cancel
518
+ }
519
+ return .use((certificate, privateKey))
520
+ default:
521
+ return .cancel
522
+ }
523
+ }
524
+
525
+ static func recordStartupFailure(_ error: Error) {
526
+ print("tokamak runtime startup failed: \(error)")
527
+ guard let state = try? stateDirectory() else { return }
528
+ try? String(describing: error).write(
529
+ to: state.appendingPathComponent(startupErrorFile),
530
+ atomically: true,
531
+ encoding: .utf8
532
+ )
533
+ }
534
+
535
+ /// Stores behind storage bindings, which device backups include.
536
+ private static func storageDirectory() throws -> URL {
537
+ let storage = try appDirectory(.applicationSupportDirectory)
538
+ .appendingPathComponent("tokamak/storage", isDirectory: true)
539
+ try FileManager.default.createDirectory(at: storage, withIntermediateDirectories: true)
540
+ return storage
541
+ }
542
+
543
+ private static func stateDirectory() throws -> URL {
544
+ let state = try appDirectory(.cachesDirectory)
545
+ try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true)
546
+ return state
547
+ }
548
+
549
+ /// This app's folder in the user's `directory`.
550
+ private static func appDirectory(_ directory: FileManager.SearchPathDirectory) throws -> URL {
551
+ guard let identifier = Bundle.main.bundleIdentifier else {
552
+ throw RuntimeError.configuration("CFBundleIdentifier is required")
553
+ }
554
+ return try FileManager.default.url(
555
+ for: directory,
556
+ in: .userDomainMask,
557
+ appropriateFor: nil,
558
+ create: true
559
+ )
560
+ .appendingPathComponent(identifier, isDirectory: true)
561
+ }
562
+ }
563
+
564
+ extension TokamakBytes {
565
+ /// A copy of the buffer, or nil when it is null.
566
+ fileprivate var contents: Data? {
567
+ data.map { Data(bytes: $0, count: len) }
568
+ }
569
+ }
@@ -0,0 +1,20 @@
1
+ import Foundation
2
+ import WebKit
3
+
4
+ extension URL {
5
+ /// Whether this URL is on the app origin, `https://<host>`.
6
+ func isAppOrigin(_ host: String) -> Bool {
7
+ scheme?.caseInsensitiveCompare("https") == .orderedSame
8
+ && self.host?.caseInsensitiveCompare(host) == .orderedSame
9
+ && (port == nil || port == 443)
10
+ }
11
+ }
12
+
13
+ extension WKSecurityOrigin {
14
+ /// Whether this is the app origin, `https://<host>`.
15
+ func isAppOrigin(_ host: String) -> Bool {
16
+ self.protocol.caseInsensitiveCompare("https") == .orderedSame
17
+ && self.host.caseInsensitiveCompare(host) == .orderedSame
18
+ && (port == 0 || port == 443)
19
+ }
20
+ }