@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,544 @@
1
+ import AVKit
2
+ import CoreLocation
3
+ import ExpoModulesCore
4
+ import Photos
5
+
6
+ /// User-visible album in Apple Photos for glasses sync (matches dedicated-folder behavior on Android).
7
+ private enum MentraSyncedMediaAlbum {
8
+ static let localizedTitle = "Mentra"
9
+ }
10
+
11
+ public class CrustModule: Module {
12
+ public func definition() -> ModuleDefinition {
13
+ Name("Crust")
14
+
15
+ Constant("PI") {
16
+ Double.pi
17
+ }
18
+
19
+ Events(
20
+ "onChange",
21
+ "phone_notification",
22
+ "phone_notification_dismissed",
23
+ "captions_tester_incident",
24
+ "onNavManeuver",
25
+ "onNavRerouting",
26
+ "onNavArrived",
27
+ "onNavError",
28
+ "onNavOffRoute",
29
+ "onNavLocation",
30
+ "onNavRoute",
31
+ "onHeading",
32
+ // MentraJS — fires whenever a per-miniapp JSContext calls
33
+ // __dispatch(iface, method, args). RN-side MentraJSRouter
34
+ // subscribes to route by packageName.
35
+ "mentrajs_message"
36
+ )
37
+
38
+ OnCreate {
39
+ // Wire the JSCRuntime's outbound event sink to the Expo
40
+ // event emitter. The JSCDispatcher fires `forwardToRn` for
41
+ // anything that isn't a built-in route (localStorage, fetch,
42
+ // crypto.getRandomBytes, __runtime.ready), and the runtime's
43
+ // exception/log/error handlers route through the same sink.
44
+ JSCRuntime.shared.onOutbound = { [weak self] message in
45
+ self?.sendEvent("mentrajs_message", message.payload)
46
+ }
47
+ // Install the polyfill bridge once, lazily on first module
48
+ // creation. JSCPolyfillBridge.install is idempotent.
49
+ JSCPolyfillBridge.install(into: JSCRuntime.shared.dispatcherTable)
50
+ }
51
+
52
+ Function("hello") {
53
+ "Hello world! 👋"
54
+ }
55
+
56
+ AsyncFunction("setValueAsync") { (value: String) in
57
+ self.sendEvent("onChange", [
58
+ "value": value,
59
+ ])
60
+ }
61
+
62
+ AsyncFunction("requestNavigationPermission") { () -> [String: Any] in
63
+ await withCheckedContinuation { continuation in
64
+ // NavigationManager is @MainActor-isolated; hop onto the main
65
+ // actor before touching it from this nonisolated AsyncFunction.
66
+ Task { @MainActor in
67
+ NavigationManager.shared.requestPermission { accepted in
68
+ continuation.resume(returning: ["ok": true, "accepted": accepted])
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ // Mapbox has no Terms & Conditions dialog (that was Google-specific),
75
+ // so there's nothing to reset. No-op for parity with Android, whose
76
+ // resetTermsAccepted is also a Mapbox no-op. Returns ok:true since the
77
+ // "reset" is trivially satisfied (no accepted-terms state exists).
78
+ AsyncFunction("resetNavigationPermission") { () -> [String: Any] in
79
+ return ["ok": true]
80
+ }
81
+
82
+ AsyncFunction("startNavigation") { (lat: Double, lng: Double, options: [String: Any]?) -> [String: Any] in
83
+ let simulate = options?["simulate"] as? Bool ?? false
84
+ let speedMultiplier = options?["speedMultiplier"] as? Double ?? 1.0
85
+ let mode = options?["mode"] as? String ?? "driving"
86
+ // Opt-in: when > 0, the NavigationManager forces a reroute as
87
+ // soon as the user is this many meters past a pivot they
88
+ // didn't take. nil disables the check entirely.
89
+ let missedTurnRerouteMeters: Double? = {
90
+ if let d = options?["missedTurnRerouteMeters"] as? Double { return d > 0 ? d : nil }
91
+ if let i = options?["missedTurnRerouteMeters"] as? Int { return i > 0 ? Double(i) : nil }
92
+ return nil
93
+ }()
94
+
95
+ var stops: [(lat: Double, lng: Double)] = []
96
+ if let stopsArr = options?["stops"] as? [[String: Double]] {
97
+ stops = stopsArr.compactMap { s in
98
+ guard let slat = s["lat"], let slng = s["lng"] else { return nil }
99
+ return (lat: slat, lng: slng)
100
+ }
101
+ }
102
+ if stops.isEmpty { stops = [(lat: lat, lng: lng)] }
103
+
104
+ return await withCheckedContinuation { continuation in
105
+ // NavigationManager is @MainActor-isolated; hop onto the main actor
106
+ // before calling start() from this nonisolated AsyncFunction.
107
+ Task { @MainActor in
108
+ NavigationManager.shared.start(
109
+ stops: stops,
110
+ mode: mode,
111
+ simulate: simulate,
112
+ speedMultiplier: speedMultiplier,
113
+ missedTurnRerouteMeters: missedTurnRerouteMeters,
114
+ onEvent: { [weak self] payload in
115
+ guard let self else { return }
116
+ let kind = payload["kind"] as? String ?? ""
117
+ switch kind {
118
+ case "maneuver": self.sendEvent("onNavManeuver", payload)
119
+ case "rerouting": self.sendEvent("onNavRerouting", payload)
120
+ case "arrived": self.sendEvent("onNavArrived", payload)
121
+ case "off_route": self.sendEvent("onNavOffRoute", payload)
122
+ case "error": self.sendEvent("onNavError", payload)
123
+ default: break
124
+ }
125
+ },
126
+ onLocation: { [weak self] payload in
127
+ self?.sendEvent("onNavLocation", payload)
128
+ },
129
+ onRoute: { [weak self] payload in
130
+ self?.sendEvent("onNavRoute", payload)
131
+ }
132
+ ) { ok, error in
133
+ var result: [String: Any] = ["ok": ok]
134
+ if let error { result["error"] = error }
135
+ continuation.resume(returning: result)
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ AsyncFunction("stopNavigation") { () -> [String: Any] in
142
+ await MainActor.run { NavigationManager.shared.stop() }
143
+ return ["ok": true]
144
+ }
145
+
146
+ AsyncFunction("simulateDeviation") { (offsetMeters: Double?) -> [String: Any] in
147
+ await MainActor.run { NavigationManager.shared.simulateDeviation(offsetMeters: offsetMeters ?? 50) }
148
+ return ["ok": true]
149
+ }
150
+
151
+ // iOS doesn't implement the dev toggles yet. Return an explicit
152
+ // error so the JS side can surface "not supported" instead of
153
+ // silently believing the call succeeded.
154
+ AsyncFunction("setWrongSidewalkOffset") { (_: Bool) -> [String: Any] in
155
+ return ["ok": false, "error": "Not implemented on iOS"]
156
+ }
157
+ AsyncFunction("setSkipCrossings") { (_: Bool) -> [String: Any] in
158
+ return ["ok": false, "error": "Not implemented on iOS"]
159
+ }
160
+
161
+ AsyncFunction("startHeading") { () -> [String: Any] in
162
+ HeadingManager.shared.start { [weak self] degrees in
163
+ self?.sendEvent("onHeading", ["degrees": degrees])
164
+ }
165
+ return ["ok": true]
166
+ }
167
+
168
+ AsyncFunction("stopHeading") { () -> [String: Any] in
169
+ HeadingManager.shared.stop()
170
+ return ["ok": true]
171
+ }
172
+
173
+ // Location:
174
+
175
+ AsyncFunction("showLocationServicesDialog") { () -> Bool in
176
+ return false
177
+ }
178
+
179
+ AsyncFunction("openLocationSettings") { () -> Bool in
180
+ return false
181
+ }
182
+
183
+ AsyncFunction("openAppSettings") { () -> Bool in
184
+ return false
185
+ }
186
+
187
+ AsyncFunction("openBluetoothSettings") { () -> Bool in
188
+ return false
189
+ }
190
+
191
+ // MARK: - MentraOS Notification Commands
192
+
193
+ AsyncFunction("setNotificationConfig") { (_: Bool, _: [String]) in
194
+ // No-op on iOS
195
+ }
196
+
197
+ AsyncFunction("getInstalledApps") { () -> [[String: Any]] in
198
+ return []
199
+ }
200
+
201
+ AsyncFunction("getInstalledAppsForNotifications") { () -> [[String: Any]] in
202
+ return []
203
+ }
204
+
205
+ AsyncFunction("hasNotificationListenerPermission") { () -> Bool in
206
+ return false
207
+ }
208
+
209
+ AsyncFunction("openNotificationListenerSettings") { () -> Bool in
210
+ return false
211
+ }
212
+
213
+ // MARK: - MentraJS Runtime
214
+
215
+ /// Spawn a per-miniapp JS context. Re-spawn is allowed: a live
216
+ /// context with the same packageName is killed first.
217
+ /// Returns true if the polyfill + miniapp source evaluated without
218
+ /// throwing. On failure the context is torn down.
219
+ AsyncFunction("mentraJsSpawn") { (packageName: String, polyfillBundle: String, miniappJs: String) -> Bool in
220
+ return JSCRuntime.shared.spawn(
221
+ packageName: packageName,
222
+ polyfillBundle: polyfillBundle,
223
+ miniappJs: miniappJs
224
+ )
225
+ }
226
+
227
+ /// Evaluate an arbitrary script inside the named context. Returns
228
+ /// the JS return value bridged to a JSON-friendly Swift type, or
229
+ /// nil if the context is dead / eval threw. Mostly for dev tooling
230
+ /// + tests; production code paths use mentraJsDispatchToJs.
231
+ AsyncFunction("mentraJsEvaluate") { (packageName: String, source: String) -> Any? in
232
+ return JSCRuntime.shared.evaluate(packageName: packageName, source: source)
233
+ }
234
+
235
+ /// Tear down a JS context. Cancels timers, drops refs, forces GC.
236
+ AsyncFunction("mentraJsKill") { (packageName: String) -> Void in
237
+ JSCRuntime.shared.kill(packageName: packageName)
238
+ }
239
+
240
+ /// Push an event / response envelope into the named context's
241
+ /// globalThis.__deliver. Used by MentraJSRouter for
242
+ /// glasses-status broadcasts and request/response correlation.
243
+ AsyncFunction("mentraJsDispatchToJs") { (packageName: String, envelope: [String: Any]) -> Void in
244
+ JSCRuntime.shared.dispatchToJs(packageName: packageName, envelope: envelope)
245
+ }
246
+
247
+ /// Set the installed manifest for a miniapp so the dispatcher's
248
+ /// permission gate can authorize sensitive `__dispatch` calls.
249
+ AsyncFunction("mentraJsSetManifest") { (packageName: String, permissions: [String]) -> Void in
250
+ JSCRuntime.shared.dispatcherTable.setManifest(
251
+ packageName: packageName,
252
+ manifest: InstalledMiniappManifest(permissions: Set(permissions))
253
+ )
254
+ }
255
+
256
+ /// Diagnostic: list all live packageNames.
257
+ Function("mentraJsAlivePackages") { () -> [String] in
258
+ return JSCRuntime.shared.alivePackages()
259
+ }
260
+
261
+ /// Diagnostic: force a JSC garbage collection cycle on the named
262
+ /// context. Used by memory-leak hunts + tests. Returns false when
263
+ /// the context is dead.
264
+ AsyncFunction("mentraJsDebugForceGC") { (packageName: String) -> Bool in
265
+ return JSCRuntime.shared.debugForceGC(packageName: packageName)
266
+ }
267
+
268
+ /// Read the bundled MentraJS polyfill (startup.js) from the iOS
269
+ /// pod's resource bundle. The host calls this once on app boot,
270
+ /// caches the string, and passes it to every mentraJsSpawn so
271
+ /// every JSContext starts with the same polyfill ABI.
272
+ Function("mentraJsLoadPolyfillBundle") { () -> String in
273
+ return JSCRuntime.loadPolyfillBundle()
274
+ }
275
+
276
+ // MARK: - Build Environment
277
+
278
+ AsyncFunction("isBetaBuild") { () -> Bool in
279
+ #if targetEnvironment(simulator)
280
+ return false
281
+ #else
282
+ return Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
283
+ #endif
284
+ }
285
+
286
+ // Configure which screen edges defer iOS system gestures
287
+ // (Control Center, Notification Center, Home indicator). Edge
288
+ // strings: "top", "bottom", "left", "right", "all". An empty
289
+ // array restores default behavior. iOS-only; Android is a no-op.
290
+ AsyncFunction("setDeferredSystemGestures") { (edges: [String]) -> Void in
291
+ var rect: UIRectEdge = []
292
+ for edge in edges {
293
+ switch edge.lowercased() {
294
+ case "top": rect.insert(.top)
295
+ case "bottom": rect.insert(.bottom)
296
+ case "left": rect.insert(.left)
297
+ case "right": rect.insert(.right)
298
+ case "all": rect = .all
299
+ default: break
300
+ }
301
+ }
302
+ SystemGestures.setDeferredEdges(rect)
303
+ }
304
+
305
+ Function("showAVRoutePicker") { (tintColor: String?) in
306
+ DispatchQueue.main.async {
307
+ let picker = AVRoutePickerView()
308
+ picker.prioritizesVideoDevices = false
309
+
310
+ if let colorString = tintColor {
311
+ picker.tintColor = UIColor(hexString: colorString)
312
+ } else {
313
+ picker.tintColor = .label
314
+ }
315
+
316
+ if let button = picker.subviews.first(where: { $0 is UIButton }) as? UIButton {
317
+ button.sendActions(for: .touchUpInside)
318
+ }
319
+ }
320
+ }
321
+
322
+ View(CrustView.self) {
323
+ Prop("url") { (view: CrustView, url: URL) in
324
+ if view.webView.url != url {
325
+ view.webView.load(URLRequest(url: url))
326
+ }
327
+ }
328
+
329
+ Events("onLoad")
330
+ }
331
+
332
+ // MARK: - Image Processing Commands
333
+
334
+ AsyncFunction("processGalleryImage") {
335
+ (inputPath: String, outputPath: String, options: [String: Any]) -> [String: Any] in
336
+ let lensCorrection = options["lensCorrection"] as? Bool ?? true
337
+ let colorCorrection = options["colorCorrection"] as? Bool ?? true
338
+
339
+ guard FileManager.default.fileExists(atPath: inputPath) else {
340
+ return ["success": false, "error": "Input file does not exist"]
341
+ }
342
+
343
+ let processingTimeMs = ImageProcessor.process(
344
+ inputPath: inputPath,
345
+ outputPath: outputPath,
346
+ lensCorrection: lensCorrection,
347
+ colorCorrection: colorCorrection
348
+ )
349
+
350
+ if processingTimeMs >= 0 {
351
+ return [
352
+ "success": true,
353
+ "outputPath": outputPath,
354
+ "processingTimeMs": processingTimeMs,
355
+ ]
356
+ } else {
357
+ return ["success": false, "error": "Processing failed"]
358
+ }
359
+ }
360
+
361
+ // MARK: - HDR Merge Commands
362
+
363
+ AsyncFunction("mergeHdrBrackets") {
364
+ (underPath: String, normalPath: String, overPath: String, outputPath: String)
365
+ -> [String: Any] in
366
+ let processingTimeMs = ImageProcessor.mergeHdr(
367
+ underPath: underPath,
368
+ normalPath: normalPath,
369
+ overPath: overPath,
370
+ outputPath: outputPath
371
+ )
372
+ if processingTimeMs >= 0 {
373
+ return [
374
+ "success": true,
375
+ "outputPath": outputPath,
376
+ "processingTimeMs": processingTimeMs,
377
+ ]
378
+ } else {
379
+ return ["success": false, "error": "HDR merge failed"]
380
+ }
381
+ }
382
+
383
+ // MARK: - Video Stabilization Commands
384
+
385
+ AsyncFunction("stabilizeVideo") {
386
+ (inputPath: String, imuPath: String, outputPath: String) -> [String: Any] in
387
+ guard FileManager.default.fileExists(atPath: inputPath) else {
388
+ return ["success": false, "error": "Input video does not exist"]
389
+ }
390
+ guard FileManager.default.fileExists(atPath: imuPath) else {
391
+ return ["success": false, "error": "IMU sidecar does not exist"]
392
+ }
393
+
394
+ let processingTimeMs = VideoStabilizer.stabilize(
395
+ inputPath: inputPath,
396
+ imuPath: imuPath,
397
+ outputPath: outputPath
398
+ )
399
+
400
+ if processingTimeMs >= 0 {
401
+ return [
402
+ "success": true,
403
+ "outputPath": outputPath,
404
+ "processingTimeMs": processingTimeMs,
405
+ ]
406
+ } else {
407
+ return ["success": false, "error": "Stabilization failed"]
408
+ }
409
+ }
410
+
411
+ // MARK: - Media Library Commands
412
+
413
+ AsyncFunction("saveToGalleryWithDate") {
414
+ (filePath: String, captureTimeMillis: Int64?, displayName: String?) -> [String: Any] in
415
+ let fileURL = URL(fileURLWithPath: filePath)
416
+
417
+ guard FileManager.default.fileExists(atPath: filePath) else {
418
+ return ["success": false, "error": "File does not exist"]
419
+ }
420
+
421
+ var assetIdentifier: String?
422
+ let semaphore = DispatchSemaphore(value: 0)
423
+ var resultError: Error?
424
+ var creationFailed = false
425
+
426
+ PHPhotoLibrary.shared().performChanges {
427
+ let pathExtension = fileURL.pathExtension.lowercased()
428
+
429
+ let creationRequest: PHAssetChangeRequest
430
+ if ["mp4", "mov", "avi", "m4v"].contains(pathExtension) {
431
+ guard let req = PHAssetChangeRequest.creationRequestForAssetFromVideo(
432
+ atFileURL: fileURL
433
+ )
434
+ else {
435
+ NSLog("CrustModule: Failed to create video asset request for: \(filePath)")
436
+ creationFailed = true
437
+ return
438
+ }
439
+ creationRequest = req
440
+ } else {
441
+ guard let req = PHAssetChangeRequest.creationRequestForAssetFromImage(
442
+ atFileURL: fileURL
443
+ )
444
+ else {
445
+ NSLog("CrustModule: Failed to create image asset request for: \(filePath)")
446
+ creationFailed = true
447
+ return
448
+ }
449
+ creationRequest = req
450
+ }
451
+
452
+ if let captureMillis = captureTimeMillis {
453
+ let captureDate = Date(
454
+ timeIntervalSince1970: TimeInterval(captureMillis) / 1000.0
455
+ )
456
+ creationRequest.creationDate = captureDate
457
+ NSLog("CrustModule: Setting creation date to: \(captureDate)")
458
+ }
459
+
460
+ guard let assetPlaceholder = creationRequest.placeholderForCreatedAsset else {
461
+ NSLog("CrustModule: Missing placeholder for created asset")
462
+ creationFailed = true
463
+ return
464
+ }
465
+
466
+ assetIdentifier = assetPlaceholder.localIdentifier
467
+
468
+ let albumFetch = PHFetchOptions()
469
+ albumFetch.predicate = NSPredicate(
470
+ format: "localizedTitle == %@", MentraSyncedMediaAlbum.localizedTitle
471
+ )
472
+ albumFetch.fetchLimit = 1
473
+
474
+ let existingAlbums = PHAssetCollection.fetchAssetCollections(
475
+ with: .album,
476
+ subtype: .albumRegular,
477
+ options: albumFetch
478
+ )
479
+
480
+ if let album = existingAlbums.firstObject,
481
+ let albumChange = PHAssetCollectionChangeRequest(for: album)
482
+ {
483
+ albumChange.addAssets([assetPlaceholder] as NSArray)
484
+ } else if existingAlbums.firstObject == nil {
485
+ let newAlbumChange = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(
486
+ withTitle: MentraSyncedMediaAlbum.localizedTitle
487
+ )
488
+ newAlbumChange.addAssets([assetPlaceholder] as NSArray)
489
+ } else {
490
+ NSLog(
491
+ "CrustModule: Mentra album exists but is not writable; asset saved to library only"
492
+ )
493
+ }
494
+ } completionHandler: { _, error in
495
+ resultError = error
496
+ semaphore.signal()
497
+ }
498
+
499
+ semaphore.wait()
500
+
501
+ if creationFailed {
502
+ return ["success": false, "error": "Failed to create asset request - file may be corrupted or unsupported"]
503
+ }
504
+
505
+ if let error = resultError {
506
+ NSLog("CrustModule: Error saving to gallery: \(error.localizedDescription)")
507
+ return ["success": false, "error": error.localizedDescription]
508
+ }
509
+
510
+ NSLog("CrustModule: Successfully saved to gallery with proper creation date")
511
+ return ["success": true, "identifier": assetIdentifier ?? ""]
512
+ }
513
+
514
+ }
515
+ }
516
+
517
+ extension UIColor {
518
+ convenience init?(hexString: String) {
519
+ var hex = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
520
+ hex = hex.replacingOccurrences(of: "#", with: "")
521
+
522
+ var rgb: UInt64 = 0
523
+ guard Scanner(string: hex).scanHexInt64(&rgb) else { return nil }
524
+
525
+ let length = hex.count
526
+ let r, g, b, a: CGFloat
527
+
528
+ if length == 6 {
529
+ r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
530
+ g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
531
+ b = CGFloat(rgb & 0x0000FF) / 255.0
532
+ a = 1.0
533
+ } else if length == 8 {
534
+ r = CGFloat((rgb & 0xFF00_0000) >> 24) / 255.0
535
+ g = CGFloat((rgb & 0x00FF_0000) >> 16) / 255.0
536
+ b = CGFloat((rgb & 0x0000_FF00) >> 8) / 255.0
537
+ a = CGFloat(rgb & 0x0000_00FF) / 255.0
538
+ } else {
539
+ return nil
540
+ }
541
+
542
+ self.init(red: r, green: g, blue: b, alpha: a)
543
+ }
544
+ }
@@ -0,0 +1,38 @@
1
+ import ExpoModulesCore
2
+ import WebKit
3
+
4
+ /// This view will be used as a native component. Make sure to inherit from `ExpoView`
5
+ /// to apply the proper styling (e.g. border radius and shadows).
6
+ class CrustView: ExpoView {
7
+ let webView = WKWebView()
8
+ let onLoad = EventDispatcher()
9
+ var delegate: WebViewDelegate?
10
+
11
+ required init(appContext: AppContext? = nil) {
12
+ super.init(appContext: appContext)
13
+ clipsToBounds = true
14
+ delegate = WebViewDelegate { url in
15
+ self.onLoad(["url": url])
16
+ }
17
+ webView.navigationDelegate = delegate
18
+ addSubview(webView)
19
+ }
20
+
21
+ override func layoutSubviews() {
22
+ webView.frame = bounds
23
+ }
24
+ }
25
+
26
+ class WebViewDelegate: NSObject, WKNavigationDelegate {
27
+ let onUrlChange: (String) -> Void
28
+
29
+ init(onUrlChange: @escaping (String) -> Void) {
30
+ self.onUrlChange = onUrlChange
31
+ }
32
+
33
+ func webView(_ webView: WKWebView, didFinish _: WKNavigation) {
34
+ if let url = webView.url {
35
+ onUrlChange(url.absoluteString)
36
+ }
37
+ }
38
+ }