@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,309 @@
1
+ import Foundation
2
+ import WebKit
3
+
4
+ struct TokamakPluginError: Error {
5
+ let name: String
6
+ let message: String
7
+
8
+ static func notSupported(_ message: String) -> Self {
9
+ Self(name: "NotSupportedError", message: message)
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
+ )
17
+ }
18
+
19
+ typealias TokamakPluginReply = (Result<Any?, TokamakPluginError>) -> Void
20
+
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 it
32
+ /// is in the foreground. A hidden or minimised macOS app still shows system
33
+ /// prompts. Call it on the main thread, where the plugin decides to show
34
+ /// UI.
35
+ func requireUI() throws(TokamakPluginError) {
36
+ #if os(iOS)
37
+ guard app.isInForeground else { throw .needsUI }
38
+ #endif
39
+ }
40
+
41
+ /// Delivers the plugin's event `name`, which the Worker's listeners
42
+ /// receive as `<plugin>.<name>`, with `event`, JSON-serialisable, and calls
43
+ /// `completion` on the main thread with their reply, parsed from JSON, or
44
+ /// nil. Fails unless they return within `timeout`, which includes runtime
45
+ /// startup. Call it on the main thread.
46
+ func emit(
47
+ _ name: String,
48
+ event: Any,
49
+ timeout: TimeInterval,
50
+ completion: @escaping (Result<Any?, Error>) -> Void
51
+ ) {
52
+ app.emit("\(plugin).\(name)", event: event, timeout: timeout, completion: completion)
53
+ }
54
+
55
+ /// Fetches what the app serves at `path`, as its WebView would load it: a
56
+ /// file the app ships or the Worker's response. Calls `completion` on the
57
+ /// main thread with the body. Fails unless it arrives within `timeout`,
58
+ /// which includes runtime startup. Call it on the main thread.
59
+ func fetch(
60
+ _ path: String,
61
+ timeout: TimeInterval,
62
+ completion: @escaping (Result<Data, Error>) -> Void
63
+ ) {
64
+ app.fetch(path, timeout: timeout, completion: completion)
65
+ }
66
+ }
67
+
68
+ /// A native plugin, created once per process when the app launches. The page
69
+ /// and the Worker call it on the main thread.
70
+ protocol TokamakPlugin: AnyObject {
71
+ var id: String { get }
72
+
73
+ init(host: TokamakHost)
74
+
75
+ func call(
76
+ method: String,
77
+ arguments: Any,
78
+ reply: @escaping TokamakPluginReply
79
+ )
80
+
81
+ /// Replies with each value until the returned function is called. Throws
82
+ /// when the subscription cannot start, which ends it.
83
+ func subscribe(
84
+ method: String,
85
+ arguments: Any,
86
+ reply: @escaping TokamakPluginReply
87
+ ) throws(TokamakPluginError) -> (() -> Void)
88
+
89
+ func didRegisterForRemoteNotifications(deviceToken: Data)
90
+
91
+ func didFailToRegisterForRemoteNotifications(error: Error)
92
+
93
+ /// Handles a remote notification delivered to the app, calling `completion`
94
+ /// once when finished with it.
95
+ func didReceiveRemoteNotification(
96
+ _ userInfo: [AnyHashable: Any],
97
+ completion: @escaping (TokamakBackgroundResult) -> Void
98
+ )
99
+ }
100
+
101
+ extension TokamakPlugin {
102
+ func call(
103
+ method: String,
104
+ arguments: Any,
105
+ reply: @escaping TokamakPluginReply
106
+ ) {
107
+ reply(.failure(.notSupported("\(id).\(method) is not supported")))
108
+ }
109
+
110
+ func subscribe(
111
+ method: String,
112
+ arguments: Any,
113
+ reply: @escaping TokamakPluginReply
114
+ ) throws(TokamakPluginError) -> (() -> Void) {
115
+ throw .notSupported("\(id).\(method) is not supported")
116
+ }
117
+
118
+ func didRegisterForRemoteNotifications(deviceToken: Data) {}
119
+
120
+ func didFailToRegisterForRemoteNotifications(error: Error) {}
121
+
122
+ func didReceiveRemoteNotification(
123
+ _ userInfo: [AnyHashable: Any],
124
+ completion: @escaping (TokamakBackgroundResult) -> Void
125
+ ) {
126
+ completion(.noData)
127
+ }
128
+ }
129
+
130
+ /// Runs one caller's calls and subscriptions, the page's or the Worker's, on
131
+ /// the app's plugins, keyed by `Key`. `send` receives each result on the main
132
+ /// thread, as the JSON object the page's native transport receives without its
133
+ /// session and ID. Use it on the main thread.
134
+ final class TokamakPluginRequests<Key: Hashable> {
135
+ private let plugins: [String: any TokamakPlugin]
136
+ private let send: (Key, [String: Any]) -> Void
137
+ private var cancellations: [Key: () -> Void] = [:]
138
+
139
+ init(plugins: [String: any TokamakPlugin], send: @escaping (Key, [String: Any]) -> Void) {
140
+ self.plugins = plugins
141
+ self.send = send
142
+ }
143
+
144
+ /// Calls `method` of `plugin`, or subscribes to it when `subscribe`.
145
+ func run(_ key: Key, subscribe: Bool, plugin: String, method: String, arguments: Any) {
146
+ guard let target = plugins[plugin], !method.isEmpty else {
147
+ fail(key, .notSupported("Plugin is not supported"))
148
+ return
149
+ }
150
+ // Plugins reply from any thread.
151
+ let reply: TokamakPluginReply = { [weak self] result in
152
+ DispatchQueue.main.async {
153
+ self?.send(key, Self.result(result, done: !subscribe))
154
+ }
155
+ }
156
+ guard subscribe else {
157
+ target.call(method: method, arguments: arguments, reply: reply)
158
+ return
159
+ }
160
+ do {
161
+ cancellations[key] = try target.subscribe(method: method, arguments: arguments, reply: reply)
162
+ } catch {
163
+ fail(key, error)
164
+ }
165
+ }
166
+
167
+ /// Ends the request `key` with `error`.
168
+ func fail(_ key: Key, _ error: TokamakPluginError) {
169
+ send(key, Self.result(.failure(error), done: true))
170
+ }
171
+
172
+ /// Ends the subscription `key`.
173
+ func cancel(_ key: Key) {
174
+ cancellations.removeValue(forKey: key)?()
175
+ }
176
+
177
+ /// Ends every subscription.
178
+ func cancelAll() {
179
+ let cancelAll = Array(cancellations.values)
180
+ cancellations.removeAll()
181
+ for cancel in cancelAll {
182
+ cancel()
183
+ }
184
+ }
185
+
186
+ /// `result` as JSON, or the failure to make it JSON.
187
+ private static func result(
188
+ _ result: Result<Any?, TokamakPluginError>,
189
+ done: Bool
190
+ ) -> [String: Any] {
191
+ switch result {
192
+ case .success(let value):
193
+ let json: [String: Any] = ["value": value ?? NSNull(), "done": done]
194
+ if JSONSerialization.isValidJSONObject(json) {
195
+ return json
196
+ }
197
+ let error = TokamakPluginError(
198
+ name: "OperationError", message: "The plugin's value is not JSON")
199
+ return Self.result(.failure(error), done: done)
200
+ case .failure(let error):
201
+ return ["error": ["name": error.name, "message": error.message], "done": done]
202
+ }
203
+ }
204
+ }
205
+
206
+ final class TokamakPluginBridge: NSObject, WKScriptMessageHandler {
207
+ private struct RequestKey: Hashable {
208
+ let session: String
209
+ let id: Int
210
+ }
211
+
212
+ private let host: String
213
+ private let plugins: [String: any TokamakPlugin]
214
+ private lazy var requests = TokamakPluginRequests<RequestKey>(plugins: plugins) {
215
+ [weak self] key, result in
216
+ self?.deliver(key: key, result: result)
217
+ }
218
+ weak var webView: WKWebView?
219
+
220
+ init(host: String, plugins: [String: any TokamakPlugin]) {
221
+ self.host = host
222
+ self.plugins = plugins
223
+ }
224
+
225
+ func install(in controller: WKUserContentController) {
226
+ controller.add(self, name: "tokamak")
227
+ controller.addUserScript(
228
+ WKUserScript(
229
+ source: Self.bootstrap(host: host),
230
+ injectionTime: .atDocumentStart,
231
+ forMainFrameOnly: true
232
+ )
233
+ )
234
+ }
235
+
236
+ func close() {
237
+ requests.cancelAll()
238
+ }
239
+
240
+ func userContentController(
241
+ _ userContentController: WKUserContentController,
242
+ didReceive message: WKScriptMessage
243
+ ) {
244
+ guard
245
+ message.frameInfo.isMainFrame,
246
+ message.frameInfo.securityOrigin.isAppOrigin(host),
247
+ let encoded = message.body as? String,
248
+ let data = encoded.data(using: .utf8),
249
+ let request = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
250
+ let type = request["type"] as? String,
251
+ let session = request["session"] as? String
252
+ else {
253
+ return
254
+ }
255
+
256
+ if type == "reset" {
257
+ close()
258
+ return
259
+ }
260
+ guard let id = request["id"] as? Int else { return }
261
+ let key = RequestKey(session: session, id: id)
262
+ switch type {
263
+ case "cancel":
264
+ requests.cancel(key)
265
+ case "call", "subscribe":
266
+ requests.run(
267
+ key,
268
+ subscribe: type == "subscribe",
269
+ plugin: request["plugin"] as? String ?? "",
270
+ method: request["method"] as? String ?? "",
271
+ arguments: request["arguments"] ?? NSNull()
272
+ )
273
+ default:
274
+ requests.fail(key, .notSupported("Plugin operation is not supported"))
275
+ }
276
+ }
277
+
278
+ private func deliver(key: RequestKey, result: [String: Any]) {
279
+ var response = result
280
+ response["session"] = key.session
281
+ response["id"] = key.id
282
+ guard
283
+ let data = try? JSONSerialization.data(withJSONObject: response),
284
+ let json = String(data: data, encoding: .utf8),
285
+ webView?.url?.isAppOrigin(host) == true
286
+ else {
287
+ return
288
+ }
289
+ webView?.callAsyncJavaScript(
290
+ "globalThis.__tokamakNative?.onmessage?.({ data })",
291
+ arguments: ["data": json],
292
+ in: nil,
293
+ in: .page
294
+ )
295
+ }
296
+
297
+ private static func bootstrap(host: String) -> String {
298
+ """
299
+ if (globalThis.location.origin === "https://\(host)") {
300
+ globalThis.__tokamakNative = {
301
+ onmessage: null,
302
+ postMessage(message) {
303
+ globalThis.webkit.messageHandlers.tokamak.postMessage(message);
304
+ }
305
+ };
306
+ }
307
+ """
308
+ }
309
+ }