@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.
- package/README.md +35 -0
- package/android/build.gradle +146 -0
- package/android/src/internal/AndroidManifest.xml +9 -0
- package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
- package/android/src/main/AndroidManifest.xml +19 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +882 -0
- package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
- package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +246 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
- package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
- package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
- package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/app.plugin.js +3 -0
- package/build/Crust.types.d.ts +148 -0
- package/build/Crust.types.d.ts.map +1 -0
- package/build/Crust.types.js +2 -0
- package/build/Crust.types.js.map +1 -0
- package/build/CrustModule.d.ts +175 -0
- package/build/CrustModule.d.ts.map +1 -0
- package/build/CrustModule.js +4 -0
- package/build/CrustModule.js.map +1 -0
- package/build/CrustModule.web.d.ts +26 -0
- package/build/CrustModule.web.d.ts.map +1 -0
- package/build/CrustModule.web.js +54 -0
- package/build/CrustModule.web.js.map +1 -0
- package/build/CrustView.d.ts +4 -0
- package/build/CrustView.d.ts.map +1 -0
- package/build/CrustView.js +7 -0
- package/build/CrustView.js.map +1 -0
- package/build/CrustView.web.d.ts +4 -0
- package/build/CrustView.web.d.ts.map +1 -0
- package/build/CrustView.web.js +7 -0
- package/build/CrustView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Crust.podspec +65 -0
- package/ios/CrustModule.swift +544 -0
- package/ios/CrustView.swift +38 -0
- package/ios/Resources/startup.js +814 -0
- package/ios/Source/JSCDispatcher.swift +226 -0
- package/ios/Source/JSCPolyfillBridge.swift +378 -0
- package/ios/Source/JSCRuntime.swift +673 -0
- package/ios/Source/utils/ImageProcessor.swift +392 -0
- package/ios/Source/utils/SystemGestures.swift +53 -0
- package/ios/Source/utils/VideoStabilizer.swift +374 -0
- package/ios/heading/HeadingManager.swift +74 -0
- package/ios/navigation/NavPayloads.swift +62 -0
- package/ios/navigation/NavigationManager.swift +720 -0
- package/package.json +69 -0
- package/plugin/build/index.d.ts +19 -0
- package/plugin/build/index.js +23 -0
- package/plugin/build/withAndroid.d.ts +2 -0
- package/plugin/build/withAndroid.js +78 -0
- package/src/Crust.types.ts +157 -0
- package/src/CrustModule.ts +186 -0
- package/src/CrustModule.web.ts +57 -0
- package/src/CrustView.tsx +10 -0
- package/src/CrustView.web.tsx +11 -0
- 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
|
+
}
|