@mentra/crust 0.1.0-dev.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 (66) hide show
  1. package/README.md +35 -0
  2. package/android/build.gradle +146 -0
  3. package/android/src/internal/AndroidManifest.xml +9 -0
  4. package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
  5. package/android/src/main/AndroidManifest.xml +19 -0
  6. package/android/src/main/java/com/mentra/crust/CrustModule.kt +882 -0
  7. package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
  8. package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
  9. package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
  10. package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +246 -0
  11. package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
  12. package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
  13. package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
  14. package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
  15. package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
  16. package/android/src/main/res/values/strings.xml +3 -0
  17. package/app.plugin.js +3 -0
  18. package/build/Crust.types.d.ts +148 -0
  19. package/build/Crust.types.d.ts.map +1 -0
  20. package/build/Crust.types.js +2 -0
  21. package/build/Crust.types.js.map +1 -0
  22. package/build/CrustModule.d.ts +175 -0
  23. package/build/CrustModule.d.ts.map +1 -0
  24. package/build/CrustModule.js +4 -0
  25. package/build/CrustModule.js.map +1 -0
  26. package/build/CrustModule.web.d.ts +26 -0
  27. package/build/CrustModule.web.d.ts.map +1 -0
  28. package/build/CrustModule.web.js +54 -0
  29. package/build/CrustModule.web.js.map +1 -0
  30. package/build/CrustView.d.ts +4 -0
  31. package/build/CrustView.d.ts.map +1 -0
  32. package/build/CrustView.js +7 -0
  33. package/build/CrustView.js.map +1 -0
  34. package/build/CrustView.web.d.ts +4 -0
  35. package/build/CrustView.web.d.ts.map +1 -0
  36. package/build/CrustView.web.js +7 -0
  37. package/build/CrustView.web.js.map +1 -0
  38. package/build/index.d.ts +4 -0
  39. package/build/index.d.ts.map +1 -0
  40. package/build/index.js +6 -0
  41. package/build/index.js.map +1 -0
  42. package/expo-module.config.json +9 -0
  43. package/ios/Crust.podspec +65 -0
  44. package/ios/CrustModule.swift +544 -0
  45. package/ios/CrustView.swift +38 -0
  46. package/ios/Resources/startup.js +814 -0
  47. package/ios/Source/JSCDispatcher.swift +226 -0
  48. package/ios/Source/JSCPolyfillBridge.swift +378 -0
  49. package/ios/Source/JSCRuntime.swift +673 -0
  50. package/ios/Source/utils/ImageProcessor.swift +392 -0
  51. package/ios/Source/utils/SystemGestures.swift +53 -0
  52. package/ios/Source/utils/VideoStabilizer.swift +374 -0
  53. package/ios/heading/HeadingManager.swift +74 -0
  54. package/ios/navigation/NavPayloads.swift +62 -0
  55. package/ios/navigation/NavigationManager.swift +720 -0
  56. package/package.json +69 -0
  57. package/plugin/build/index.d.ts +19 -0
  58. package/plugin/build/index.js +23 -0
  59. package/plugin/build/withAndroid.d.ts +2 -0
  60. package/plugin/build/withAndroid.js +78 -0
  61. package/src/Crust.types.ts +157 -0
  62. package/src/CrustModule.ts +186 -0
  63. package/src/CrustModule.web.ts +57 -0
  64. package/src/CrustView.tsx +10 -0
  65. package/src/CrustView.web.tsx +11 -0
  66. package/src/index.ts +5 -0
@@ -0,0 +1,226 @@
1
+ //
2
+ // JSCDispatcher.swift
3
+ // MentraJS — dispatch table + permission gate for the per-miniapp
4
+ // __dispatch(iface, method, args) bridge.
5
+ //
6
+ // Every JSContext spawned by JSCRuntime gets a single Swift block bound
7
+ // to __dispatch. That block hands control to JSCDispatcher.handle(),
8
+ // which:
9
+ // 1. Looks up the (iface, method) handler from `routes` — local
10
+ // handlers serve dispatches synchronously, RN-forwarded ones return
11
+ // .forwardToRn and the runtime emits an `mentrajs_message` event.
12
+ // 2. Consults the InstalledManifest to gate OS-sensitive calls.
13
+ // PERMISSION_NOT_DECLARED errors propagate back to JS as thrown
14
+ // Error objects. There is no JIT permission modal — declared-in-
15
+ // manifest is the only gate (matches the cloud-WebView lifecycle
16
+ // where the install step is the consent step).
17
+ //
18
+ // Most native heavy lifting (display, mic, camera, BLE) is owned by the
19
+ // existing RN-side runtime, so the default route is .forwardToRn. Only
20
+ // the bridge-internal calls (`__runtime.ready`, `localStorage.*`,
21
+ // `crypto.getRandomBytes`, `__log`) get inline native handlers — those
22
+ // are too hot to round-trip through RN on every call.
23
+ //
24
+
25
+ import Foundation
26
+ import os.log
27
+
28
+ /// Outcome of a `__dispatch` call as far as the JS side cares.
29
+ public enum JSCDispatchOutcome {
30
+ /// Local synchronous handler completed; return value goes back to JS.
31
+ case sync(Any?)
32
+ /// Handler accepted the call and will resolve later via dispatchToJs.
33
+ case async
34
+ /// Handler refused; JS sees a thrown Error with `code` + `message`.
35
+ case error(code: String, message: String?)
36
+ /// Forward to RN as `mentrajs_message`; downstream RN code handles it
37
+ /// and (for request/response calls) eventually calls dispatchToJs back.
38
+ case forwardToRn([String: Any])
39
+ }
40
+
41
+ /// Per-package manifest declared in `miniapp.json`. Mirrors the structure
42
+ /// LocalMiniappRuntime owns today.
43
+ public struct InstalledMiniappManifest {
44
+ public let permissions: Set<String>
45
+ public init(permissions: Set<String>) {
46
+ self.permissions = permissions
47
+ }
48
+ }
49
+
50
+ public final class JSCDispatcher {
51
+ /// (iface, method) → handler closure.
52
+ /// Closure runs ON the JSContext's queue (caller's responsibility to
53
+ /// hand off if it needs the main thread).
54
+ public typealias Handler = (_ packageName: String, _ args: [Any], _ reqId: String?) -> JSCDispatchOutcome
55
+
56
+ private var routes: [String: Handler] = [:]
57
+ private var manifests: [String: InstalledMiniappManifest] = [:]
58
+ /// Permissions that don't require manifest declaration — every miniapp
59
+ /// implicitly has these (legacy behavior).
60
+ private let implicitGrants: Set<String> = ["STORAGE", "DISPLAY", "BUTTONS"]
61
+ private let lock = NSLock()
62
+
63
+ /// Required permission per iface. Missing entry = no permission gate.
64
+ /// Mirrors the inline permission checks in LocalMiniappRuntime today.
65
+ public var permissionRequirements: [String: String] = [
66
+ "mic": "MICROPHONE",
67
+ "transcription": "MICROPHONE",
68
+ "translation": "MICROPHONE",
69
+ "camera": "CAMERA",
70
+ "location": "LOCATION",
71
+ "navigation": "LOCATION",
72
+ "heading": "LOCATION",
73
+ "calendar": "CALENDAR",
74
+ ]
75
+
76
+ public init() {
77
+ installBuiltinRoutes()
78
+ }
79
+
80
+ public func register(iface: String, method: String, handler: @escaping Handler) {
81
+ let key = "\(iface).\(method)"
82
+ lock.withLockVoid { routes[key] = handler }
83
+ }
84
+
85
+ /// Drop a previously-registered route. Useful for tests + hot-reload.
86
+ public func unregister(iface: String, method: String) {
87
+ let key = "\(iface).\(method)"
88
+ lock.withLockVoid { routes.removeValue(forKey: key) }
89
+ }
90
+
91
+ /// Set the installed manifest for a package — usually called when the
92
+ /// miniapp's bundle is registered by the host. The dispatcher reads
93
+ /// `permissions` for the static "declared in manifest" gate.
94
+ public func setManifest(packageName: String, manifest: InstalledMiniappManifest) {
95
+ lock.withLockVoid { manifests[packageName] = manifest }
96
+ }
97
+
98
+ public func clearManifest(packageName: String) {
99
+ lock.withLockVoid { manifests.removeValue(forKey: packageName) }
100
+ }
101
+
102
+ public func manifest(packageName: String) -> InstalledMiniappManifest? {
103
+ lock.withLock { manifests[packageName] }
104
+ }
105
+
106
+ /// Main entry point — called from JSCRuntime's __dispatch block.
107
+ public func handle(
108
+ packageName: String,
109
+ iface: String,
110
+ method: String,
111
+ args: [Any],
112
+ reqId: String?,
113
+ ) -> JSCDispatchOutcome {
114
+ // Permission gate. Bridge-internal calls (`__runtime`, `__log`,
115
+ // crypto, localStorage) are exempt — they don't touch OS sensors.
116
+ // Only the manifest declaration matters — there is no JIT prompt;
117
+ // the install step is the consent step (matches the cloud-WebView
118
+ // miniapp lifecycle).
119
+ if let required = permissionRequirement(for: iface), !implicitGrants.contains(required) {
120
+ let manifestPermissions = manifest(packageName: packageName)?.permissions ?? []
121
+ if !manifestPermissions.contains(required) {
122
+ return .error(code: "PERMISSION_NOT_DECLARED", message: required)
123
+ }
124
+ }
125
+
126
+ let key = "\(iface).\(method)"
127
+ if let handler = (lock.withLock { routes[key] }) {
128
+ return handler(packageName, args, reqId)
129
+ }
130
+
131
+ // No local route → forward to RN. The RN-side MentraJSRouter
132
+ // is the destination.
133
+ return .forwardToRn([
134
+ "args": args,
135
+ ])
136
+ }
137
+
138
+ private func permissionRequirement(for iface: String) -> String? {
139
+ permissionRequirements[iface]
140
+ }
141
+
142
+ // MARK: - Built-in local routes
143
+
144
+ private func installBuiltinRoutes() {
145
+ // __runtime.ready — the polyfill bundle's signal-ready callback.
146
+ // JSCRuntime watches for this to clear the cold-start NACK timer.
147
+ register(iface: "__runtime", method: "ready") { packageName, _, _ in
148
+ JSCRuntime.shared.markReady(packageName: packageName)
149
+ return .sync(NSNull())
150
+ }
151
+
152
+ // localStorage — bridged synchronously through Userdefaults. Each
153
+ // miniapp gets its own UserDefaults suite scoped on packageName.
154
+ register(iface: "localStorage", method: "getItem") { packageName, args, _ in
155
+ guard let key = args.first as? String,
156
+ let defaults = UserDefaults(suiteName: "MentraJS-\(packageName)") else {
157
+ return .sync(NSNull())
158
+ }
159
+ let v = defaults.string(forKey: key)
160
+ return .sync(v ?? NSNull())
161
+ }
162
+ register(iface: "localStorage", method: "setItem") { packageName, args, _ in
163
+ guard args.count >= 2,
164
+ let key = args[0] as? String,
165
+ let value = args[1] as? String,
166
+ let defaults = UserDefaults(suiteName: "MentraJS-\(packageName)") else {
167
+ return .error(code: "INVALID_ARGS", message: "localStorage.setItem expects (key, value) strings")
168
+ }
169
+ defaults.set(value, forKey: key)
170
+ return .sync(NSNull())
171
+ }
172
+ register(iface: "localStorage", method: "removeItem") { packageName, args, _ in
173
+ guard let key = args.first as? String,
174
+ let defaults = UserDefaults(suiteName: "MentraJS-\(packageName)") else {
175
+ return .error(code: "INVALID_ARGS", message: "localStorage.removeItem expects (key)")
176
+ }
177
+ defaults.removeObject(forKey: key)
178
+ return .sync(NSNull())
179
+ }
180
+ register(iface: "localStorage", method: "clear") { packageName, _, _ in
181
+ guard let defaults = UserDefaults(suiteName: "MentraJS-\(packageName)") else {
182
+ return .sync(NSNull())
183
+ }
184
+ for (k, _) in defaults.dictionaryRepresentation() {
185
+ defaults.removeObject(forKey: k)
186
+ }
187
+ return .sync(NSNull())
188
+ }
189
+ register(iface: "localStorage", method: "length") { packageName, _, _ in
190
+ guard let defaults = UserDefaults(suiteName: "MentraJS-\(packageName)") else {
191
+ return .sync(0)
192
+ }
193
+ return .sync(defaults.dictionaryRepresentation().count)
194
+ }
195
+ register(iface: "localStorage", method: "key") { packageName, args, _ in
196
+ guard let idx = args.first as? Int,
197
+ let defaults = UserDefaults(suiteName: "MentraJS-\(packageName)") else {
198
+ return .sync(NSNull())
199
+ }
200
+ let keys = Array(defaults.dictionaryRepresentation().keys).sorted()
201
+ guard idx >= 0, idx < keys.count else { return .sync(NSNull()) }
202
+ return .sync(keys[idx])
203
+ }
204
+
205
+ // crypto.getRandomBytes — single fast path used by the polyfill's
206
+ // getRandomValues + randomUUID. Returns [byte] array.
207
+ register(iface: "crypto", method: "getRandomBytes") { _, args, _ in
208
+ let n: Int
209
+ if let nn = args.first as? Int { n = nn }
210
+ else if let nd = args.first as? Double { n = Int(nd) }
211
+ else { return .error(code: "INVALID_ARGS", message: "getRandomBytes expects (n)") }
212
+ guard n >= 0, n <= (1 << 20) else {
213
+ return .error(code: "INVALID_ARGS", message: "getRandomBytes max 1MB")
214
+ }
215
+ var bytes = [UInt8](repeating: 0, count: n)
216
+ let status = bytes.withUnsafeMutableBufferPointer { buf -> Int32 in
217
+ guard let baseAddress = buf.baseAddress else { return errSecParam }
218
+ return SecRandomCopyBytes(kSecRandomDefault, n, baseAddress)
219
+ }
220
+ if status != errSecSuccess {
221
+ return .error(code: "NATIVE_THROW", message: "SecRandomCopyBytes failed (\(status))")
222
+ }
223
+ return .sync(bytes.map { Int($0) })
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,378 @@
1
+ //
2
+ // JSCPolyfillBridge.swift
3
+ // MentraJS — native handlers for the browser-flavoured polyfills that
4
+ // can't be implemented in pure JS (fetch, WebSocket).
5
+ //
6
+ // Built-in JS shims (console, timers, localStorage, crypto.randomUUID)
7
+ // live in JSCDispatcher.swift because they're small synchronous routes
8
+ // that the dispatcher serves inline. The async networking surfaces here
9
+ // warrant their own file: each request opens a URLSession task that
10
+ // outlives the originating __dispatch call, so the handler must return
11
+ // .async and schedule a dispatchToJs callback when the task finishes.
12
+ //
13
+
14
+ import Foundation
15
+ import os.log
16
+
17
+ /// Hooks the network polyfill routes into a JSCDispatcher. Call once on
18
+ /// host boot, after the dispatcher is created. Idempotent.
19
+ public enum JSCPolyfillBridge {
20
+ /// Single URLSession shared by fetch (closure-based dataTasks) and
21
+ /// WebSocket (delegate-driven). Apple's URLSession supports mixing:
22
+ /// dataTasks with completion handlers bypass the delegate for data
23
+ /// callbacks, while WebSocket-specific delegate methods still fire
24
+ /// for `webSocketTask(with:)`. One session = one connection pool =
25
+ /// one place to configure TLS, proxies, timeouts.
26
+ ///
27
+ /// The delegate is required so we fire JS-side `open` only after the
28
+ /// server has accepted the handshake (matches RFC 6455 + the browser
29
+ /// WebSocket spec). Without it, onopen could fire before the server
30
+ /// has actually upgraded.
31
+ private static let session: URLSession = URLSession(
32
+ configuration: .default,
33
+ delegate: WebSocketSessionDelegate.shared,
34
+ delegateQueue: nil,
35
+ )
36
+
37
+ fileprivate final class WebSocketSessionDelegate: NSObject, URLSessionWebSocketDelegate {
38
+ static let shared = WebSocketSessionDelegate()
39
+
40
+ func urlSession(
41
+ _ session: URLSession,
42
+ webSocketTask: URLSessionWebSocketTask,
43
+ didOpenWithProtocol protocol_: String?,
44
+ ) {
45
+ // Look up the wrapper for this task and fire the JS-side open.
46
+ JSCPolyfillBridge.socketsLock.lock()
47
+ let wrapper = JSCPolyfillBridge.sockets.values.first { $0.task === webSocketTask }
48
+ JSCPolyfillBridge.socketsLock.unlock()
49
+ guard let wrapper else { return }
50
+ var payload: [String: Any] = [:]
51
+ if let proto = protocol_, !proto.isEmpty {
52
+ payload["protocol"] = proto
53
+ }
54
+ JSCPolyfillBridge.deliverWebSocketEvent(
55
+ packageName: wrapper.packageName,
56
+ sid: wrapper.sid,
57
+ wsType: "open",
58
+ payload: payload,
59
+ )
60
+ }
61
+
62
+ func urlSession(
63
+ _ session: URLSession,
64
+ webSocketTask: URLSessionWebSocketTask,
65
+ didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
66
+ reason: Data?,
67
+ ) {
68
+ JSCPolyfillBridge.socketsLock.lock()
69
+ let wrapper = JSCPolyfillBridge.sockets.values.first { $0.task === webSocketTask }
70
+ JSCPolyfillBridge.socketsLock.unlock()
71
+ guard let wrapper else { return }
72
+ if wrapper.closedSent { return }
73
+ wrapper.closedSent = true
74
+ let reasonStr = reason.flatMap { String(data: $0, encoding: .utf8) } ?? ""
75
+ JSCPolyfillBridge.deliverWebSocketEvent(
76
+ packageName: wrapper.packageName,
77
+ sid: wrapper.sid,
78
+ wsType: "close",
79
+ payload: ["code": closeCode.rawValue, "reason": reasonStr],
80
+ )
81
+ JSCPolyfillBridge.dropSocket(sid: wrapper.sid)
82
+ }
83
+ }
84
+ private static let log = OSLog(subsystem: "com.mentra.mentra", category: "MentraJS.fetch")
85
+
86
+ public static func install(into dispatcher: JSCDispatcher) {
87
+ installFetch(dispatcher)
88
+ installWebSocket(dispatcher)
89
+ }
90
+
91
+ private static func installFetch(_ dispatcher: JSCDispatcher) {
92
+ dispatcher.register(iface: "fetch", method: "request") { packageName, args, reqId in
93
+ guard let req = args.first as? [String: Any],
94
+ let urlString = req["url"] as? String,
95
+ let url = URL(string: urlString) else {
96
+ return .error(code: "INVALID_ARGS", message: "fetch.request expects {url, method, headers, body}")
97
+ }
98
+ guard let reqId else {
99
+ return .error(code: "INVALID_ARGS", message: "fetch.request requires reqId (use __mentraSendRequest)")
100
+ }
101
+
102
+ var urlRequest = URLRequest(url: url)
103
+ urlRequest.httpMethod = (req["method"] as? String) ?? "GET"
104
+ if let headers = req["headers"] as? [String: Any] {
105
+ for (k, v) in headers {
106
+ urlRequest.setValue(String(describing: v), forHTTPHeaderField: k)
107
+ }
108
+ }
109
+ if let body = req["body"] as? String, !body.isEmpty {
110
+ urlRequest.httpBody = body.data(using: .utf8)
111
+ }
112
+
113
+ // Reasonable defaults — caller can override later by passing
114
+ // {timeoutMs} in the request shape, but the polyfill doesn't
115
+ // surface that yet.
116
+ urlRequest.timeoutInterval = 60
117
+
118
+ let task = session.dataTask(with: urlRequest) { data, response, error in
119
+ if let error {
120
+ let payload: [String: Any] = [
121
+ "kind": "response",
122
+ "reqId": reqId,
123
+ "ok": false,
124
+ "error": [
125
+ "code": "NATIVE_THROW",
126
+ "message": "fetch: \(error.localizedDescription)",
127
+ ],
128
+ ]
129
+ JSCRuntime.shared.dispatchToJs(packageName: packageName, envelope: payload)
130
+ return
131
+ }
132
+ let http = response as? HTTPURLResponse
133
+ let status = http?.statusCode ?? 0
134
+ let headersDict: [String: String] = {
135
+ guard let http else { return [:] }
136
+ var out: [String: String] = [:]
137
+ for (k, v) in http.allHeaderFields {
138
+ if let ks = k as? String, let vs = v as? String {
139
+ out[ks.lowercased()] = vs
140
+ }
141
+ }
142
+ return out
143
+ }()
144
+ let bodyStr: String = {
145
+ guard let data else { return "" }
146
+ return String(data: data, encoding: .utf8) ?? ""
147
+ }()
148
+ let payload: [String: Any] = [
149
+ "kind": "response",
150
+ "reqId": reqId,
151
+ "ok": true,
152
+ "result": [
153
+ "status": status,
154
+ "statusText": HTTPURLResponse.localizedString(forStatusCode: status),
155
+ "headers": headersDict,
156
+ "body": bodyStr,
157
+ "ok": (200..<300).contains(status),
158
+ ] as [String: Any],
159
+ ]
160
+ JSCRuntime.shared.dispatchToJs(packageName: packageName, envelope: payload)
161
+ }
162
+ task.resume()
163
+ return .async
164
+ }
165
+ }
166
+
167
+ // MARK: - WebSocket
168
+
169
+ /// Per-sid map of active socket tasks. The wrapper holds the
170
+ /// URLSessionWebSocketTask and pumps inbound frames back via __deliver.
171
+ private static var sockets: [String: WebSocketTaskWrapper] = [:]
172
+ private static let socketsLock = NSLock()
173
+
174
+ private final class WebSocketTaskWrapper {
175
+ let sid: String
176
+ let packageName: String
177
+ let task: URLSessionWebSocketTask
178
+ var closedSent = false
179
+
180
+ init(sid: String, packageName: String, request: URLRequest, session: URLSession) {
181
+ self.sid = sid
182
+ self.packageName = packageName
183
+ self.task = session.webSocketTask(with: request)
184
+ }
185
+
186
+ func resume() {
187
+ task.resume()
188
+ readNext()
189
+ }
190
+
191
+ func send(text: String, completion: @escaping (Error?) -> Void) {
192
+ task.send(.string(text), completionHandler: completion)
193
+ }
194
+
195
+ func send(binary: Data, completion: @escaping (Error?) -> Void) {
196
+ task.send(.data(binary), completionHandler: completion)
197
+ }
198
+
199
+ func close(code: URLSessionWebSocketTask.CloseCode, reason: String?) {
200
+ let data = reason?.data(using: .utf8)
201
+ task.cancel(with: code, reason: data)
202
+ }
203
+
204
+ private func readNext() {
205
+ task.receive { [weak self] result in
206
+ guard let self else { return }
207
+ switch result {
208
+ case .success(let msg):
209
+ switch msg {
210
+ case .string(let text):
211
+ JSCPolyfillBridge.deliverWebSocketEvent(
212
+ packageName: self.packageName,
213
+ sid: self.sid,
214
+ wsType: "message",
215
+ payload: ["kind": "text", "data": text],
216
+ )
217
+ case .data(let data):
218
+ let b64 = data.base64EncodedString()
219
+ JSCPolyfillBridge.deliverWebSocketEvent(
220
+ packageName: self.packageName,
221
+ sid: self.sid,
222
+ wsType: "message",
223
+ payload: ["kind": "binary", "data": b64],
224
+ )
225
+ @unknown default:
226
+ break
227
+ }
228
+ self.readNext()
229
+ case .failure(let error):
230
+ JSCPolyfillBridge.deliverWebSocketEvent(
231
+ packageName: self.packageName,
232
+ sid: self.sid,
233
+ wsType: "error",
234
+ payload: ["message": error.localizedDescription],
235
+ )
236
+ if !self.closedSent {
237
+ self.closedSent = true
238
+ JSCPolyfillBridge.deliverWebSocketEvent(
239
+ packageName: self.packageName,
240
+ sid: self.sid,
241
+ wsType: "close",
242
+ payload: ["code": 1006, "reason": error.localizedDescription],
243
+ )
244
+ JSCPolyfillBridge.dropSocket(sid: self.sid)
245
+ }
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ private static func installWebSocket(_ dispatcher: JSCDispatcher) {
252
+ dispatcher.register(iface: "ws", method: "open") { packageName, args, _ in
253
+ guard let req = args.first as? [String: Any],
254
+ let sid = req["sid"] as? String,
255
+ let urlString = req["url"] as? String,
256
+ let url = URL(string: urlString) else {
257
+ return .error(code: "INVALID_ARGS", message: "ws.open expects {sid, url, protocols?}")
258
+ }
259
+ var urlRequest = URLRequest(url: url)
260
+ if let protocols = req["protocols"] as? [String], !protocols.isEmpty {
261
+ urlRequest.setValue(protocols.joined(separator: ", "), forHTTPHeaderField: "Sec-WebSocket-Protocol")
262
+ }
263
+ let wrapper = WebSocketTaskWrapper(
264
+ sid: sid,
265
+ packageName: packageName,
266
+ request: urlRequest,
267
+ session: session,
268
+ )
269
+ socketsLock.lock()
270
+ sockets[sid] = wrapper
271
+ socketsLock.unlock()
272
+ wrapper.resume()
273
+ // `open` is fired by the URLSessionWebSocketDelegate's
274
+ // didOpenWithProtocol callback once the server has accepted
275
+ // the handshake — not synthesized here. JS code that calls
276
+ // send() before that fires is queued internally by
277
+ // URLSessionWebSocketTask (it's safe to call pre-open).
278
+ return .sync(NSNull())
279
+ }
280
+
281
+ dispatcher.register(iface: "ws", method: "send") { _, args, _ in
282
+ guard let req = args.first as? [String: Any],
283
+ let sid = req["sid"] as? String,
284
+ let kind = req["kind"] as? String,
285
+ let payload = req["payload"] as? String else {
286
+ return .error(code: "INVALID_ARGS", message: "ws.send expects {sid, kind, payload}")
287
+ }
288
+ socketsLock.lock()
289
+ let wrapper = sockets[sid]
290
+ socketsLock.unlock()
291
+ guard let wrapper else {
292
+ return .error(code: "INVALID_ARGS", message: "ws.send: unknown sid")
293
+ }
294
+ let pkg = wrapper.packageName
295
+ if kind == "text" {
296
+ wrapper.send(text: payload) { error in
297
+ if let error {
298
+ JSCPolyfillBridge.deliverWebSocketEvent(
299
+ packageName: pkg,
300
+ sid: sid,
301
+ wsType: "error",
302
+ payload: ["message": error.localizedDescription],
303
+ )
304
+ }
305
+ }
306
+ } else if kind == "binary" {
307
+ guard let data = Data(base64Encoded: payload) else {
308
+ return .error(code: "INVALID_ARGS", message: "ws.send: bad base64")
309
+ }
310
+ wrapper.send(binary: data) { error in
311
+ if let error {
312
+ JSCPolyfillBridge.deliverWebSocketEvent(
313
+ packageName: pkg,
314
+ sid: sid,
315
+ wsType: "error",
316
+ payload: ["message": error.localizedDescription],
317
+ )
318
+ }
319
+ }
320
+ } else {
321
+ return .error(code: "INVALID_ARGS", message: "ws.send: kind must be text|binary")
322
+ }
323
+ return .sync(NSNull())
324
+ }
325
+
326
+ dispatcher.register(iface: "ws", method: "close") { _, args, _ in
327
+ guard let req = args.first as? [String: Any],
328
+ let sid = req["sid"] as? String else {
329
+ return .error(code: "INVALID_ARGS", message: "ws.close expects {sid, code?, reason?}")
330
+ }
331
+ socketsLock.lock()
332
+ let wrapper = sockets[sid]
333
+ socketsLock.unlock()
334
+ guard let wrapper else {
335
+ return .sync(NSNull())
336
+ }
337
+ let rawCode = (req["code"] as? Int) ?? 1000
338
+ let code = URLSessionWebSocketTask.CloseCode(rawValue: rawCode) ?? .goingAway
339
+ wrapper.close(code: code, reason: req["reason"] as? String)
340
+ if !wrapper.closedSent {
341
+ wrapper.closedSent = true
342
+ JSCPolyfillBridge.deliverWebSocketEvent(
343
+ packageName: wrapper.packageName,
344
+ sid: sid,
345
+ wsType: "close",
346
+ payload: ["code": rawCode, "reason": (req["reason"] as? String) ?? ""],
347
+ )
348
+ socketsLock.lock()
349
+ sockets[sid] = nil
350
+ socketsLock.unlock()
351
+ }
352
+ return .sync(NSNull())
353
+ }
354
+ }
355
+
356
+ /// Push a ws-event envelope to the JS side. The polyfill bundle's
357
+ /// __deliver dispatches kind="ws-event" to the matching socket.
358
+ fileprivate static func deliverWebSocketEvent(
359
+ packageName: String,
360
+ sid: String,
361
+ wsType: String,
362
+ payload: [String: Any],
363
+ ) {
364
+ let envelope: [String: Any] = [
365
+ "kind": "ws-event",
366
+ "sid": sid,
367
+ "wsType": wsType,
368
+ "payload": payload,
369
+ ]
370
+ JSCRuntime.shared.dispatchToJs(packageName: packageName, envelope: envelope)
371
+ }
372
+
373
+ fileprivate static func dropSocket(sid: String) {
374
+ socketsLock.lock()
375
+ sockets[sid] = nil
376
+ socketsLock.unlock()
377
+ }
378
+ }