@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.
- package/ios-simulator-x64/build/entrypoint +2 -2
- package/ios-simulator-x64/frameworks/TokamakRuntime.framework/Headers/TokamakRuntime.h +21 -6
- package/ios-simulator-x64/frameworks/TokamakRuntime.framework/TokamakRuntime +0 -0
- package/ios-simulator-x64/native-shell/{TokamakHost.swift → TokamakApp.swift} +197 -34
- package/ios-simulator-x64/native-shell/TokamakPlugins.swift +144 -71
- package/ios-simulator-x64/native-shell/TokamakShell.swift +64 -16
- package/package.json +1 -1
|
@@ -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(
|
|
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:
|
|
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,
|
|
30
|
-
|
|
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
|
|
36
|
-
const char *
|
|
37
|
-
TokamakBytes *
|
|
38
|
-
|
|
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);
|
|
Binary file
|
|
@@ -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
|
|
10
|
-
|
|
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
|
|
15
|
-
/// before launch finishes.
|
|
41
|
+
/// Creates the plugins. Call it before launch finishes.
|
|
16
42
|
init() {
|
|
17
|
-
|
|
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
|
-
///
|
|
36
|
-
///
|
|
37
|
-
///
|
|
38
|
-
///
|
|
39
|
-
|
|
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
|
-
|
|
115
|
+
event: Any,
|
|
42
116
|
timeout: TimeInterval,
|
|
43
|
-
|
|
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
|
-
|
|
126
|
+
queue.async {
|
|
52
127
|
let outcome = Result {
|
|
53
|
-
try runtime.
|
|
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
|
-
|
|
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
|
-
///
|
|
236
|
-
///
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
242
|
-
decoding: try JSONSerialization.data(withJSONObject:
|
|
389
|
+
let event = String(
|
|
390
|
+
decoding: try JSONSerialization.data(withJSONObject: event, options: .fragmentsAllowed),
|
|
243
391
|
as: UTF8.self
|
|
244
392
|
)
|
|
245
|
-
var
|
|
393
|
+
var reply = TokamakBytes()
|
|
246
394
|
var error = [CChar](repeating: 0, count: 1024)
|
|
247
395
|
let succeeded = name.withCString { name in
|
|
248
|
-
|
|
396
|
+
event.withCString { event in
|
|
249
397
|
error.withUnsafeMutableBufferPointer { error in
|
|
250
|
-
|
|
398
|
+
tokamak_runtime_emit(
|
|
251
399
|
handle,
|
|
252
400
|
name,
|
|
253
|
-
|
|
401
|
+
event,
|
|
254
402
|
UInt64(max(timeout, 0) * 1000),
|
|
255
|
-
&
|
|
403
|
+
&reply,
|
|
256
404
|
error.baseAddress,
|
|
257
405
|
error.count
|
|
258
406
|
)
|
|
259
407
|
}
|
|
260
408
|
}
|
|
261
409
|
}
|
|
262
|
-
defer { tokamak_bytes_free(
|
|
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
|
-
|
|
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
|
-
///
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
159
|
-
|
|
160
|
-
case "subscribe":
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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(
|
|
231
|
-
self.
|
|
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
|
-
|
|
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:
|
|
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
|
|
314
|
-
private lazy var controller = TokamakController(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|