@swiftbrowser/native 0.12.0 → 0.14.0

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.
Files changed (43) hide show
  1. package/README.md +49 -1
  2. package/bin/swiftbrowser-native.mjs +23 -7
  3. package/dist/bridge.d.ts +9 -1
  4. package/dist/bridge.d.ts.map +1 -1
  5. package/dist/bridge.js.map +1 -1
  6. package/dist/build.d.ts +22 -2
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +25 -8
  9. package/dist/build.js.map +1 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/openURL.d.ts +18 -0
  15. package/dist/openURL.d.ts.map +1 -0
  16. package/dist/openURL.js +35 -0
  17. package/dist/openURL.js.map +1 -0
  18. package/dist/runtime.d.ts +7 -3
  19. package/dist/runtime.d.ts.map +1 -1
  20. package/dist/runtime.js +21 -3
  21. package/dist/runtime.js.map +1 -1
  22. package/dist/site.d.ts +2 -0
  23. package/dist/site.d.ts.map +1 -1
  24. package/dist/site.js +18 -3
  25. package/dist/site.js.map +1 -1
  26. package/dist/worker.d.ts +87 -0
  27. package/dist/worker.d.ts.map +1 -0
  28. package/dist/worker.js +213 -0
  29. package/dist/worker.js.map +1 -0
  30. package/package.json +3 -3
  31. package/src/bridge.ts +14 -1
  32. package/src/build.ts +40 -8
  33. package/src/index.ts +2 -0
  34. package/src/openURL.ts +38 -0
  35. package/src/runtime.ts +21 -3
  36. package/src/site.ts +20 -3
  37. package/src/worker.ts +236 -0
  38. package/swift/Package.swift +1 -0
  39. package/swift/Sources/SwiftBrowserNative/Host/HostBridge.swift +46 -2
  40. package/swift/Sources/SwiftBrowserNative/Host/JavaScriptCoreHost.swift +2 -1
  41. package/swift/Sources/SwiftBrowserNative/Host/WorkerHost.swift +279 -0
  42. package/swift/Sources/SwiftBrowserNative/Runtime/NativeApp.swift +27 -0
  43. package/wasm/SwiftBrowserNative.wasm +2 -2
package/src/worker.ts ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Workers for a JavaScriptCore app: a second context on its own thread, as
3
+ * a page has Web Workers. The host (Native/Sources/SwiftBrowserNative/Host/WorkerHost.swift)
4
+ * runs each one in a `JSVirtualMachine` of its own with a run loop, the
5
+ * runtime's globals (timers, `fetch`, `console`, `TextDecoder`) and the
6
+ * bundle `swiftbrowser-native build --worker <name>=<entry>` wrote next to
7
+ * app.js (`<name>.worker.js`). Messages cross as text: JSON, with typed
8
+ * arrays, `ArrayBuffer`s and `Error`s encoded (`encodeMessage`), since
9
+ * values cannot be shared between virtual machines. Drawn in a page
10
+ * (`swiftbrowser-native site`), the same app code gets the page's own
11
+ * `Worker` over the same bundle, published with the site.
12
+ *
13
+ * ```ts
14
+ * import { createWorker } from '@swiftbrowser/native';
15
+ * const worker = createWorker('render'); // render.worker.js
16
+ * worker.onmessage = (event) => console.log(event.data);
17
+ * worker.postMessage({ draw: 'something' });
18
+ * ```
19
+ */
20
+
21
+ import { nativeBridge, type NativeBridge } from './bridge';
22
+ import { base64Decode, base64Encode } from './encoding';
23
+
24
+ /** The site's published path for worker bundles (`/native/bundle/App/`), defined by `swiftbrowser-native site`; undefined in an Xcode bundle. */
25
+ declare const __SB_WORKER_BASE__: string | undefined;
26
+
27
+ /** What `Worker` and a worker's `self` have in common, as an app uses them. */
28
+ export interface WorkerLike {
29
+ postMessage(data: unknown, transfer?: unknown[]): void;
30
+ terminate(): void;
31
+ onmessage: ((event: WorkerMessageEvent) => void) | null;
32
+ onerror: ((event: WorkerErrorEvent) => void) | null;
33
+ addEventListener(type: 'message', listener: (event: WorkerMessageEvent) => void): void;
34
+ addEventListener(type: 'error', listener: (event: WorkerErrorEvent) => void): void;
35
+ removeEventListener(type: 'message' | 'error', listener: (event: never) => void): void;
36
+ }
37
+
38
+ export interface WorkerMessageEvent {
39
+ type: 'message';
40
+ data: unknown;
41
+ }
42
+
43
+ export interface WorkerErrorEvent {
44
+ type: 'error';
45
+ message: string;
46
+ }
47
+
48
+ // MARK: Messages
49
+
50
+ const TYPED_ARRAYS = {
51
+ Uint8Array,
52
+ Uint8ClampedArray,
53
+ Int8Array,
54
+ Uint16Array,
55
+ Int16Array,
56
+ Uint32Array,
57
+ Int32Array,
58
+ Float32Array,
59
+ Float64Array,
60
+ } as const;
61
+ type TypedArrayName = keyof typeof TYPED_ARRAYS;
62
+
63
+ /**
64
+ * A message as text: JSON, with a typed array or `ArrayBuffer` as
65
+ * `{ $sbBytes, $sbType }` (base64) and an `Error` as `{ $sbError }`. What
66
+ * JSON drops (functions, `undefined` in an object, a `Map`) is dropped here
67
+ * too; a `Date` arrives as its ISO string.
68
+ */
69
+ export function encodeMessage(data: unknown): string {
70
+ return JSON.stringify(data === undefined ? null : data, (_key, value: unknown) => {
71
+ if (value instanceof ArrayBuffer) return { $sbBytes: base64Encode(new Uint8Array(value)), $sbType: 'ArrayBuffer' };
72
+ if (ArrayBuffer.isView(value)) {
73
+ const name = (value.constructor as { name?: string }).name;
74
+ const bytes = new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
75
+ return { $sbBytes: base64Encode(bytes), $sbType: name && name in TYPED_ARRAYS ? name : 'Uint8Array' };
76
+ }
77
+ if (value instanceof Error) return { $sbError: { name: value.name, message: value.message, stack: value.stack ?? null } };
78
+ return value;
79
+ });
80
+ }
81
+
82
+ /** The value `encodeMessage` encoded. */
83
+ export function decodeMessage(text: string): unknown {
84
+ return JSON.parse(text, (_key, value: unknown) => {
85
+ if (value && typeof value === 'object') {
86
+ const record = value as Record<string, unknown>;
87
+ if (typeof record['$sbBytes'] === 'string') {
88
+ const bytes = base64Decode(record['$sbBytes']);
89
+ const type = record['$sbType'];
90
+ if (type === 'ArrayBuffer') return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
91
+ const Constructor = TYPED_ARRAYS[(type as TypedArrayName) in TYPED_ARRAYS ? (type as TypedArrayName) : 'Uint8Array'];
92
+ // A view over the decoded bytes, aligned for the element size.
93
+ const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
94
+ return new Constructor(buffer);
95
+ }
96
+ if (record['$sbError'] && typeof record['$sbError'] === 'object') {
97
+ const spec = record['$sbError'] as { name?: string; message?: string; stack?: string | null };
98
+ const error = new Error(spec.message ?? '');
99
+ if (spec.name) error.name = spec.name;
100
+ if (spec.stack) error.stack = spec.stack;
101
+ return error;
102
+ }
103
+ }
104
+ return value;
105
+ });
106
+ }
107
+
108
+ // MARK: The app's side
109
+
110
+ type Listener<E> = (event: E) => void;
111
+
112
+ /** `Worker` over the bridge, in the app's context. */
113
+ export class NativeWorker implements WorkerLike {
114
+ private static nextId = 1;
115
+ private static readonly live = new Map<number, NativeWorker>();
116
+ readonly #id: number;
117
+ readonly #native: NativeBridge;
118
+ #terminated = false;
119
+ readonly #messageListeners = new Set<Listener<WorkerMessageEvent>>();
120
+ readonly #errorListeners = new Set<Listener<WorkerErrorEvent>>();
121
+ onmessage: Listener<WorkerMessageEvent> | null = null;
122
+ onerror: Listener<WorkerErrorEvent> | null = null;
123
+
124
+ /** `name` is the bundle's: `<name>.worker.js` next to app.js. A URL's last path segment serves as the name (`render.worker.js` → `render`). */
125
+ constructor(name: string | { href: string } | URL, native: NativeBridge | undefined = nativeBridge()) {
126
+ if (!native?.workerStart || !native.workerPost || !native.workerTerminate) {
127
+ throw new Error('Worker: this host has no workers (SwiftBrowser Native 0.13 or later)');
128
+ }
129
+ this.#native = native;
130
+ this.#id = NativeWorker.nextId++;
131
+ NativeWorker.live.set(this.#id, this);
132
+ const failure = native.workerStart(this.#id, workerName(name));
133
+ if (failure) {
134
+ NativeWorker.live.delete(this.#id);
135
+ throw new Error(failure);
136
+ }
137
+ }
138
+
139
+ postMessage(data: unknown): void {
140
+ if (this.#terminated) return;
141
+ this.#native.workerPost!(this.#id, encodeMessage(data));
142
+ }
143
+
144
+ terminate(): void {
145
+ if (this.#terminated) return;
146
+ this.#terminated = true;
147
+ NativeWorker.live.delete(this.#id);
148
+ this.#native.workerTerminate!(this.#id);
149
+ }
150
+
151
+ addEventListener(type: 'message', listener: Listener<WorkerMessageEvent>): void;
152
+ addEventListener(type: 'error', listener: Listener<WorkerErrorEvent>): void;
153
+ addEventListener(type: 'message' | 'error', listener: Listener<never>): void {
154
+ if (type === 'message') this.#messageListeners.add(listener as Listener<WorkerMessageEvent>);
155
+ else this.#errorListeners.add(listener as Listener<WorkerErrorEvent>);
156
+ }
157
+
158
+ removeEventListener(type: 'message' | 'error', listener: Listener<never>): void {
159
+ if (type === 'message') this.#messageListeners.delete(listener as Listener<WorkerMessageEvent>);
160
+ else this.#errorListeners.delete(listener as Listener<WorkerErrorEvent>);
161
+ }
162
+
163
+ /** The bridge's `__sbWorkerMessage(id, text)`. */
164
+ static receive(id: number, text: string): void {
165
+ const worker = NativeWorker.live.get(id);
166
+ if (!worker) return;
167
+ const event: WorkerMessageEvent = { type: 'message', data: decodeMessage(text) };
168
+ worker.onmessage?.(event);
169
+ for (const listener of worker.#messageListeners) listener(event);
170
+ }
171
+
172
+ /** The bridge's `__sbWorkerError(id, message)`: an uncaught exception in the worker. */
173
+ static fail(id: number, message: string): void {
174
+ const worker = NativeWorker.live.get(id);
175
+ if (!worker) return;
176
+ const event: WorkerErrorEvent = { type: 'error', message };
177
+ worker.onerror?.(event);
178
+ for (const listener of worker.#errorListeners) listener(event);
179
+ }
180
+ }
181
+
182
+ /** The bundle name a worker was asked for: `render`, or the `render` of `.../render.worker.js`. */
183
+ export function workerName(name: string | { href: string } | URL): string {
184
+ const text = typeof name === 'string' ? name : name.href;
185
+ const last = text.split('/').pop() ?? text;
186
+ return last.replace(/\.(m?[jt]sx?)$/, '').replace(/\.worker$/, '');
187
+ }
188
+
189
+ /**
190
+ * A worker from the bundle `<name>.worker.js`: the page's own `Worker` when
191
+ * the app is drawn in a page (the bundle published with the site), else the
192
+ * host's over the bridge.
193
+ */
194
+ export function createWorker(name: string): WorkerLike {
195
+ const base = typeof __SB_WORKER_BASE__ === 'string' ? __SB_WORKER_BASE__ : undefined;
196
+ const PageWorker = (globalThis as { Worker?: new (url: string) => WorkerLike }).Worker;
197
+ if (typeof PageWorker === 'function' && base !== undefined) return new PageWorker(`${base}${workerName(name)}.worker.js`);
198
+ if (nativeBridge()?.workerStart) return new NativeWorker(name);
199
+ if (typeof PageWorker === 'function') return new PageWorker(`${workerName(name)}.worker.js`);
200
+ throw new Error(`createWorker: no host for "${name}" (not in a SwiftBrowser Native app or a page)`);
201
+ }
202
+
203
+ // MARK: The worker's side
204
+
205
+ /**
206
+ * A worker's own globals over its bridge: `self`, `postMessage`, `onmessage`
207
+ * and `onerror` as properties the script assigns, `addEventListener`,
208
+ * `close`, and `__sbWorkerReceive`, which the host calls with each message.
209
+ */
210
+ export function installWorkerScope(native: NativeBridge, target: Record<string, unknown>): string[] {
211
+ const installed: string[] = [];
212
+ const define = (name: string, value: unknown): void => {
213
+ Object.defineProperty(target, name, { value, writable: true, configurable: true, enumerable: false });
214
+ installed.push(name);
215
+ };
216
+ const messageListeners = new Set<Listener<WorkerMessageEvent>>();
217
+ const errorListeners = new Set<Listener<WorkerErrorEvent>>();
218
+ define('self', target);
219
+ define('postMessage', (data: unknown) => native.workerPostMessage!(encodeMessage(data)));
220
+ define('addEventListener', (type: string, listener: Listener<never>) => {
221
+ if (type === 'message') messageListeners.add(listener as Listener<WorkerMessageEvent>);
222
+ else if (type === 'error') errorListeners.add(listener as Listener<WorkerErrorEvent>);
223
+ });
224
+ define('removeEventListener', (type: string, listener: Listener<never>) => {
225
+ if (type === 'message') messageListeners.delete(listener as Listener<WorkerMessageEvent>);
226
+ else if (type === 'error') errorListeners.delete(listener as Listener<WorkerErrorEvent>);
227
+ });
228
+ define('close', () => native.log('warn', 'close(): a worker ends when the app terminates it'));
229
+ define('__sbWorkerReceive', (text: string) => {
230
+ const event: WorkerMessageEvent = { type: 'message', data: decodeMessage(text) };
231
+ const handler = target['onmessage'];
232
+ if (typeof handler === 'function') (handler as Listener<WorkerMessageEvent>)(event);
233
+ for (const listener of messageListeners) listener(event);
234
+ });
235
+ return installed;
236
+ }
@@ -27,5 +27,6 @@ let package = Package(
27
27
  .target(name: "SwiftBrowserProtocol"),
28
28
  .target(name: "SwiftBrowserTree", dependencies: ["SwiftBrowserProtocol"]),
29
29
  .target(name: "SwiftBrowserNative", dependencies: ["SwiftBrowserProtocol", "SwiftBrowserTree"]),
30
+ // Apple platforms only (JavaScriptCore): the workers; empty elsewhere.
30
31
  ]
31
32
  )
@@ -27,14 +27,18 @@ import FoundationBridge // the shim's Foundation stand-ins (ShimAliases.swift)
27
27
  /// | `openURL(url)` | the system opener |
28
28
  /// | `locale()` | the viewer's locale identifier |
29
29
  /// | `launchArguments()` | the process's arguments (`-screen list` from a scheme or `simctl launch`) |
30
+ /// | `workerStart(id, name)`, `workerPost(id, text)`, `workerTerminate(id)` | workers (WorkerHost.swift, JavaScriptCore only); their messages come to `__sbWorkerMessage(id, text)`, their errors to `__sbWorkerError(id, message)` |
30
31
  ///
31
32
  /// Everything JavaScript-facing runs on the main thread; work that is not
32
- /// (a fetch) hops back before calling in.
33
+ /// (a fetch, a worker) hops back before calling in.
33
34
  @MainActor
34
35
  final class HostBridge {
35
36
  private let host: JSHost
36
37
  private let tree: OpTree
37
38
  private var timers: [Int: Timer] = [:]
39
+ #if canImport(JavaScriptCore)
40
+ private var workers: [Int: WorkerHost] = [:]
41
+ #endif
38
42
  private let logger = Logger(subsystem: "swiftbrowser", category: "js")
39
43
  private let session = URLSession(configuration: .default)
40
44
  /// Keys of `localStorage` live under this prefix in `UserDefaults`.
@@ -46,10 +50,14 @@ final class HostBridge {
46
50
  installAll()
47
51
  }
48
52
 
49
- /// Stops every timer; the app that owned the bridge is going away.
53
+ /// Stops every timer and worker; the app that owned the bridge is going away.
50
54
  func invalidate() {
51
55
  for timer in timers.values { timer.invalidate() }
52
56
  timers.removeAll()
57
+ #if canImport(JavaScriptCore)
58
+ for worker in workers.values { worker.terminate() }
59
+ workers.removeAll()
60
+ #endif
53
61
  }
54
62
 
55
63
  private func installAll() {
@@ -166,6 +174,42 @@ final class HostBridge {
166
174
  host.install("locale") { _ in .string(Locale.current.identifier) }
167
175
 
168
176
  host.install("launchArguments") { _ in .array(ProcessInfo.processInfo.arguments.map(JSONValue.string)) }
177
+
178
+ #if canImport(JavaScriptCore)
179
+ host.install("workerStart") { [weak self] arguments in
180
+ guard let self, let id = arguments[safe: 0]?.int, let name = arguments[safe: 1]?.string else {
181
+ return .string("workerStart: expected an id and a name")
182
+ }
183
+ do {
184
+ let worker = try WorkerHost(name: name, onMessage: { [weak self] text in
185
+ DispatchQueue.main.async {
186
+ MainActor.assumeIsolated { self?.callIn("__sbWorkerMessage", [.number(Double(id)), .string(text)]) }
187
+ }
188
+ }, onError: { [weak self] message in
189
+ DispatchQueue.main.async {
190
+ MainActor.assumeIsolated { self?.callIn("__sbWorkerError", [.number(Double(id)), .string(message)]) }
191
+ }
192
+ })
193
+ self.workers[id]?.terminate()
194
+ self.workers[id] = worker
195
+ worker.start()
196
+ return nil
197
+ } catch {
198
+ return .string("\(error)")
199
+ }
200
+ }
201
+ host.install("workerPost") { [weak self] arguments in
202
+ guard let self, let id = arguments[safe: 0]?.int, let text = arguments[safe: 1]?.string else { return nil }
203
+ self.workers[id]?.post(text)
204
+ return nil
205
+ }
206
+ host.install("workerTerminate") { [weak self] arguments in
207
+ guard let self, let id = arguments[safe: 0]?.int else { return nil }
208
+ self.workers[id]?.terminate()
209
+ self.workers[id] = nil
210
+ return nil
211
+ }
212
+ #endif
169
213
  }
170
214
 
171
215
  /// Calls into JavaScript from a timer or a completion, where a throw has nowhere to go but the log.
@@ -91,7 +91,8 @@ public final class JavaScriptCoreHost: JSHost {
91
91
 
92
92
  /// A JavaScript value as JSON. Strings stay strings without a copy
93
93
  /// through `toObject`, the common case (the ops of a pass, an event).
94
- static func json(from value: JSValue) -> JSONValue {
94
+ /// Any thread's: a worker's context (WorkerHost.swift) uses it too.
95
+ nonisolated static func json(from value: JSValue) -> JSONValue {
95
96
  if value.isUndefined || value.isNull { return .null }
96
97
  if value.isString { return .string(value.toString()) }
97
98
  if value.isBoolean { return .bool(value.toBool()) }
@@ -0,0 +1,279 @@
1
+ #if canImport(SwiftUI) && canImport(JavaScriptCore)
2
+ import Foundation
3
+ import JavaScriptCore
4
+ import os
5
+ import SwiftBrowserProtocol
6
+
7
+ /// A worker of a JavaScriptCore app (packages/native/src/worker.ts): a
8
+ /// second `JSContext` in a `JSVirtualMachine` of its own, on a thread of
9
+ /// its own, so it runs beside the app's context rather than in its turns.
10
+ /// The thread runs a run loop: the worker's timers and `fetch` answers come
11
+ /// through it, and so does the engine's own deferred work (an async
12
+ /// `WebAssembly.instantiate` settles its promise from it, which is what an
13
+ /// emscripten module's glue awaits).
14
+ ///
15
+ /// The worker's `__sbNative` has the primitives the runtime builds its
16
+ /// globals from, minus the tree (`applyOps`), plus `workerPostMessage(text)`.
17
+ /// Messages are text both ways (the runtime encodes typed arrays into it):
18
+ /// values cannot cross virtual machines. The app's side (HostBridge.swift)
19
+ /// delivers the worker's messages and errors on the main thread.
20
+ final class WorkerHost: @unchecked Sendable {
21
+ let name: String
22
+ private let script: String
23
+ private let thread: WorkerThread
24
+ private let onMessage: @Sendable (String) -> Void
25
+ private let onError: @Sendable (String) -> Void
26
+ private let logger = Logger(subsystem: "swiftbrowser", category: "worker")
27
+ private let session = URLSession(configuration: .default)
28
+ // Touched on the worker's thread only.
29
+ private var context: JSContext?
30
+ private var timers: [Int: Timer] = [:]
31
+
32
+ /// The script is `<name>.worker.js` in `bundle` (what `swiftbrowser-native
33
+ /// build --worker <name>=<entry>` writes beside app.js); throws when it is not there.
34
+ init(name: String, bundle: Bundle = .main, onMessage: @escaping @Sendable (String) -> Void, onError: @escaping @Sendable (String) -> Void) throws {
35
+ guard let url = bundle.url(forResource: "\(name).worker", withExtension: "js") else {
36
+ throw JSError(message: "\(name).worker.js is not in the app bundle (build it with `swiftbrowser-native build --worker \(name)=<entry>`)")
37
+ }
38
+ self.name = name
39
+ script = try String(contentsOf: url, encoding: .utf8)
40
+ self.onMessage = onMessage
41
+ self.onError = onError
42
+ thread = WorkerThread(label: "swiftbrowser.worker.\(name)")
43
+ }
44
+
45
+ /// Starts the thread: it creates the context, runs the script, then its
46
+ /// run loop. Messages posted before the script ran wait for it.
47
+ func start() {
48
+ thread.start { [self] in setUp() }
49
+ }
50
+
51
+ /// Delivers an encoded message to the worker's `__sbWorkerReceive`.
52
+ func post(_ text: String) {
53
+ thread.perform { [self] in call("__sbWorkerReceive", [.string(text)]) }
54
+ }
55
+
56
+ /// Ends the worker: its timers, context and thread go.
57
+ func terminate() {
58
+ thread.perform { [self] in tearDown() }
59
+ thread.stop()
60
+ }
61
+
62
+ // MARK: On the worker's thread
63
+
64
+ private func setUp() {
65
+ let context = JSContext(virtualMachine: JSVirtualMachine())!
66
+ context.name = "SwiftBrowser worker \(name)"
67
+ context.exceptionHandler = { [weak self] _, exception in
68
+ guard let self else { return }
69
+ let message = exception?.toString() ?? "unknown error"
70
+ let stack = exception?.objectForKeyedSubscript("stack")?.toString()
71
+ let text = stack.flatMap { $0 == "undefined" ? nil : "\(message)\n\($0)" } ?? message
72
+ self.logger.error("\(self.name): \(text, privacy: .public)")
73
+ self.onError(text)
74
+ }
75
+ self.context = context
76
+ let native = JSValue(newObjectIn: context)!
77
+ context.setObject(native, forKeyedSubscript: "__sbNative" as NSString)
78
+ installPrimitives(on: native)
79
+ context.evaluateScript(script, withSourceURL: URL(string: "sb://\(name).worker.js"))
80
+ }
81
+
82
+ private func tearDown() {
83
+ for timer in timers.values { timer.invalidate() }
84
+ timers.removeAll()
85
+ context?.exceptionHandler = nil
86
+ context = nil
87
+ }
88
+
89
+ private func install(_ native: JSValue, _ name: String, _ body: @escaping ([JSONValue]) -> JSONValue?) {
90
+ let block: @convention(block) () -> Any? = {
91
+ let arguments = (JSContext.currentArguments() as? [JSValue]) ?? []
92
+ return body(arguments.map(JavaScriptCoreHost.json(from:)))?.anyObject
93
+ }
94
+ native.setObject(unsafeBitCast(block, to: AnyObject.self), forKeyedSubscript: name as NSString)
95
+ }
96
+
97
+ /// Calls a global function of the worker's; an exception reaches `onError` through the handler.
98
+ @discardableResult
99
+ private func call(_ function: String, _ arguments: [JSONValue]) -> JSONValue? {
100
+ guard let context, let callee = context.objectForKeyedSubscript(function), !callee.isUndefined else { return nil }
101
+ return callee.call(withArguments: arguments.map(\.anyObject)).map(JavaScriptCoreHost.json(from:))
102
+ }
103
+
104
+ private func installPrimitives(on native: JSValue) {
105
+ install(native, "workerPostMessage") { [onMessage] arguments in
106
+ if let text = arguments.first?.string { onMessage(text) }
107
+ return nil
108
+ }
109
+
110
+ install(native, "log") { [logger, name] arguments in
111
+ let level = arguments.first?.string ?? "log"
112
+ let message = arguments.dropFirst().map { $0.string ?? $0.json }.joined(separator: " ")
113
+ switch level {
114
+ case "error": logger.error("\(name): \(message, privacy: .public)")
115
+ case "warn": logger.warning("\(name): \(message, privacy: .public)")
116
+ case "debug": logger.debug("\(name): \(message, privacy: .public)")
117
+ default: logger.info("\(name): \(message, privacy: .public)")
118
+ }
119
+ #if DEBUG
120
+ print("[\(name):\(level)] \(message)")
121
+ #endif
122
+ return nil
123
+ }
124
+
125
+ install(native, "now") { _ in .number(ProcessInfo.processInfo.systemUptime * 1000) }
126
+
127
+ install(native, "setTimer") { [weak self] arguments in
128
+ guard let self, let id = arguments[safe: 0]?.int else { return nil }
129
+ let delay = max(0, (arguments[safe: 1]?.double ?? 0) / 1000)
130
+ let repeats = arguments[safe: 2]?.bool ?? false
131
+ self.timers[id]?.invalidate()
132
+ // On this thread's run loop, so it fires on this thread.
133
+ let timer = Timer(timeInterval: delay, repeats: repeats) { [weak self] _ in
134
+ guard let self else { return }
135
+ if !repeats { self.timers[id] = nil }
136
+ self.call("__sbTimerFired", [.number(Double(id))])
137
+ }
138
+ RunLoop.current.add(timer, forMode: .default)
139
+ self.timers[id] = timer
140
+ return nil
141
+ }
142
+
143
+ install(native, "clearTimer") { [weak self] arguments in
144
+ guard let self, let id = arguments[safe: 0]?.int else { return nil }
145
+ self.timers[id]?.invalidate()
146
+ self.timers[id] = nil
147
+ return nil
148
+ }
149
+
150
+ install(native, "fetch") { [weak self] arguments in
151
+ guard let self, let id = arguments[safe: 0]?.int else { return nil }
152
+ self.fetch(id: id, request: arguments[safe: 1] ?? .null)
153
+ return nil
154
+ }
155
+
156
+ // No storage of its own: a worker keeps state in its scope.
157
+ install(native, "storageGet") { _ in .null }
158
+ install(native, "storageSet") { _ in nil }
159
+ install(native, "storageRemove") { _ in nil }
160
+ install(native, "storageKeys") { _ in .array([]) }
161
+ install(native, "storageClear") { _ in nil }
162
+
163
+ install(native, "randomBytes") { arguments in
164
+ let count = max(0, min(arguments.first?.int ?? 0, 65536))
165
+ var generator = SystemRandomNumberGenerator()
166
+ let bytes = (0..<count).map { _ in UInt8.random(in: .min ... .max, using: &generator) }
167
+ return .string(Data(bytes).base64EncodedString())
168
+ }
169
+
170
+ install(native, "openURL") { _ in .bool(false) }
171
+ install(native, "locale") { _ in .string(Locale.current.identifier) }
172
+ install(native, "launchArguments") { _ in .array(ProcessInfo.processInfo.arguments.map(JSONValue.string)) }
173
+ }
174
+
175
+ /// As HostBridge's, answering on the worker's thread.
176
+ private func fetch(id: Int, request: JSONValue) {
177
+ guard let urlString = request["url"].string, let url = URL(string: urlString) else {
178
+ call("__sbFetchResult", [.number(Double(id)), .object(["error": .string("invalid URL")])])
179
+ return
180
+ }
181
+ var urlRequest = URLRequest(url: url)
182
+ urlRequest.httpMethod = request["method"].string ?? "GET"
183
+ for (name, value) in request["headers"].object ?? [:] {
184
+ if let value = value.string { urlRequest.setValue(value, forHTTPHeaderField: name) }
185
+ }
186
+ if let body = request["body"].string, let data = Data(base64Encoded: body) {
187
+ urlRequest.httpBody = data
188
+ }
189
+ let task = session.dataTask(with: urlRequest) { [weak self] data, response, error in
190
+ let result: JSONValue
191
+ if let error {
192
+ result = .object(["error": .string(error.localizedDescription)])
193
+ } else {
194
+ let http = response as? HTTPURLResponse
195
+ var headers: JSONObject = [:]
196
+ for (name, value) in http?.allHeaderFields ?? [:] {
197
+ headers[String(describing: name).lowercased()] = .string(String(describing: value))
198
+ }
199
+ result = .object([
200
+ "status": .number(Double(http?.statusCode ?? 200)),
201
+ "statusText": .string(http.map { HTTPURLResponse.localizedString(forStatusCode: $0.statusCode) } ?? ""),
202
+ "headers": .object(headers),
203
+ "url": .string(response?.url?.absoluteString ?? urlString),
204
+ "body": .string((data ?? Data()).base64EncodedString()),
205
+ ])
206
+ }
207
+ guard let self else { return }
208
+ self.thread.perform { [self] in self.call("__sbFetchResult", [.number(Double(id)), result]) }
209
+ }
210
+ task.resume()
211
+ }
212
+ }
213
+
214
+ /// A thread with a run loop that runs blocks sent to it, in order, after
215
+ /// the block it started with.
216
+ final class WorkerThread: @unchecked Sendable {
217
+ private let label: String
218
+ private let lock = NSLock()
219
+ private var runLoop: CFRunLoop?
220
+ private var pending: [@Sendable () -> Void] = []
221
+ private var stopped = false
222
+
223
+ init(label: String) {
224
+ self.label = label
225
+ }
226
+
227
+ func start(_ setUp: @escaping @Sendable () -> Void) {
228
+ let thread = Thread { [self] in
229
+ let loop = RunLoop.current
230
+ // A port keeps the loop running while nothing else is scheduled.
231
+ loop.add(Port(), forMode: .default)
232
+ setUp()
233
+ lock.lock()
234
+ runLoop = CFRunLoopGetCurrent()
235
+ let queued = pending
236
+ pending = []
237
+ lock.unlock()
238
+ for block in queued { block() }
239
+ while !isStopped {
240
+ loop.run(mode: .default, before: .distantFuture)
241
+ }
242
+ }
243
+ thread.name = label
244
+ thread.qualityOfService = .userInitiated
245
+ thread.start()
246
+ }
247
+
248
+ /// Runs `block` on the thread: at once if it is idle, else after what it is doing.
249
+ func perform(_ block: @escaping @Sendable () -> Void) {
250
+ lock.lock()
251
+ guard let runLoop else {
252
+ pending.append(block)
253
+ lock.unlock()
254
+ return
255
+ }
256
+ lock.unlock()
257
+ CFRunLoopPerformBlock(runLoop, CFRunLoopMode.defaultMode.rawValue, block)
258
+ CFRunLoopWakeUp(runLoop)
259
+ }
260
+
261
+ private var isStopped: Bool {
262
+ lock.lock()
263
+ defer { lock.unlock() }
264
+ return stopped
265
+ }
266
+
267
+ /// Ends the loop after the blocks already sent ran.
268
+ func stop() {
269
+ lock.lock()
270
+ stopped = true
271
+ let loop = runLoop
272
+ lock.unlock()
273
+ if let loop {
274
+ CFRunLoopPerformBlock(loop, CFRunLoopMode.defaultMode.rawValue) { CFRunLoopStop(loop) }
275
+ CFRunLoopWakeUp(loop)
276
+ }
277
+ }
278
+ }
279
+ #endif
@@ -37,6 +37,9 @@ public final class NativeApp {
37
37
  public private(set) var colorScheme: String?
38
38
  public private(set) var dynamicTypeSize: String?
39
39
  public private(set) var horizontalSizeClass: String?
40
+ /// URLs the app was opened with before its script could take them (a
41
+ /// universal link that launched it): delivered once it loads.
42
+ private var pendingURLs: [URL] = []
40
43
 
41
44
  #if canImport(JavaScriptCore)
42
45
  public convenience init() {
@@ -72,11 +75,34 @@ public final class NativeApp {
72
75
  isLoaded = host.hasGlobal("__sbDispatch")
73
76
  if !isLoaded { error = "\(name) did not call run(): no __sbDispatch" }
74
77
  sendEnvironment()
78
+ deliverPendingURLs()
75
79
  } catch {
76
80
  self.error = "\(error)"
77
81
  }
78
82
  }
79
83
 
84
+ /// Hands the app a URL it was opened with (a universal link, a custom
85
+ /// scheme: SwiftUI's `onOpenURL`). JavaScript's `onOpenURL` listeners
86
+ /// (`@swiftbrowser/native`) get it; before the bundle loads, it waits.
87
+ public func open(url: URL) {
88
+ guard isLoaded, host.hasGlobal("__sbOpenURL") else {
89
+ pendingURLs.append(url)
90
+ return
91
+ }
92
+ do {
93
+ try host.call("__sbOpenURL", [.string(url.absoluteString)])
94
+ } catch {
95
+ self.error = "\(error)"
96
+ }
97
+ }
98
+
99
+ private func deliverPendingURLs() {
100
+ guard isLoaded, !pendingURLs.isEmpty else { return }
101
+ let urls = pendingURLs
102
+ pendingURLs = []
103
+ for url in urls { open(url: url) }
104
+ }
105
+
80
106
  /// Starts over for a reload: a fresh engine (the old one's globals and
81
107
  /// timers go with it) and an empty tree, which the views are observing.
82
108
  private func reset() {
@@ -188,6 +214,7 @@ public final class NativeApp {
188
214
  isLoaded = host.hasGlobal("__sbDispatch")
189
215
  if !isLoaded { error = "the scripts did not call run(): no __sbDispatch" }
190
216
  sendEnvironment()
217
+ deliverPendingURLs()
191
218
  } catch {
192
219
  self.error = "\(error)"
193
220
  }
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/wasm/SwiftBrowserNative.wasm
3
- size: 49299172 bytes
4
- sha256: 4b0ae4bc428d21234784a4c806f021482aa294045f94c5fc80eeea0ad40b5405
3
+ size: 49301069 bytes
4
+ sha256: 644cdf56fe487eb95d2c2c0e40782626f6fb70259a3ea40a6b9e11d2c23c401a