@mentra/crust 0.1.0-beta.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 +41 -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 +1042 -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 +287 -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 +182 -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 +34 -0
- package/build/CrustModule.web.d.ts.map +1 -0
- package/build/CrustModule.web.js +63 -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 +781 -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 +191 -0
- package/src/CrustModule.web.ts +66 -0
- package/src/CrustView.tsx +10 -0
- package/src/CrustView.web.tsx +11 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,781 @@
|
|
|
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
|
+
/// Serializes callback and timeout delivery for PhotoKit APIs that may call back more than once.
|
|
12
|
+
private final class CheckedContinuationGate<Value>: @unchecked Sendable {
|
|
13
|
+
private let lock = NSLock()
|
|
14
|
+
private var continuation: CheckedContinuation<Value, Never>?
|
|
15
|
+
|
|
16
|
+
init(_ continuation: CheckedContinuation<Value, Never>) {
|
|
17
|
+
self.continuation = continuation
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func resume(returning value: Value) {
|
|
21
|
+
lock.lock()
|
|
22
|
+
guard let continuation else {
|
|
23
|
+
lock.unlock()
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
self.continuation = nil
|
|
27
|
+
lock.unlock()
|
|
28
|
+
continuation.resume(returning: value)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private final class PhotoLibrarySaveState: @unchecked Sendable {
|
|
33
|
+
private let lock = NSLock()
|
|
34
|
+
private var assetIdentifier: String?
|
|
35
|
+
private var creationFailed = false
|
|
36
|
+
|
|
37
|
+
func setAssetIdentifier(_ identifier: String) {
|
|
38
|
+
lock.lock()
|
|
39
|
+
assetIdentifier = identifier
|
|
40
|
+
lock.unlock()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func markCreationFailed() {
|
|
44
|
+
lock.lock()
|
|
45
|
+
creationFailed = true
|
|
46
|
+
lock.unlock()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func snapshot() -> (assetIdentifier: String?, creationFailed: Bool) {
|
|
50
|
+
lock.lock()
|
|
51
|
+
defer { lock.unlock() }
|
|
52
|
+
return (assetIdentifier, creationFailed)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private struct PhotoLibrarySaveResult {
|
|
57
|
+
let succeeded: Bool
|
|
58
|
+
let assetIdentifier: String?
|
|
59
|
+
let creationFailed: Bool
|
|
60
|
+
let errorMessage: String?
|
|
61
|
+
let timedOut: Bool
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public class CrustModule: Module {
|
|
65
|
+
public func definition() -> ModuleDefinition {
|
|
66
|
+
Name("Crust")
|
|
67
|
+
|
|
68
|
+
Constant("PI") {
|
|
69
|
+
Double.pi
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
Events(
|
|
73
|
+
"onChange",
|
|
74
|
+
"phone_notification",
|
|
75
|
+
"phone_notification_dismissed",
|
|
76
|
+
"captions_tester_incident",
|
|
77
|
+
"onNavManeuver",
|
|
78
|
+
"onNavRerouting",
|
|
79
|
+
"onNavArrived",
|
|
80
|
+
"onNavError",
|
|
81
|
+
"onNavOffRoute",
|
|
82
|
+
"onNavLocation",
|
|
83
|
+
"onNavRoute",
|
|
84
|
+
"onHeading",
|
|
85
|
+
// MentraJS — fires whenever a per-miniapp JSContext calls
|
|
86
|
+
// __dispatch(iface, method, args). RN-side MentraJSRouter
|
|
87
|
+
// subscribes to route by packageName.
|
|
88
|
+
"mentrajs_message"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
OnCreate {
|
|
92
|
+
// Wire the JSCRuntime's outbound event sink to the Expo
|
|
93
|
+
// event emitter. The JSCDispatcher fires `forwardToRn` for
|
|
94
|
+
// anything that isn't a built-in route (localStorage, fetch,
|
|
95
|
+
// crypto.getRandomBytes, __runtime.ready), and the runtime's
|
|
96
|
+
// exception/log/error handlers route through the same sink.
|
|
97
|
+
JSCRuntime.shared.onOutbound = { [weak self] message in
|
|
98
|
+
self?.sendEvent("mentrajs_message", message.payload)
|
|
99
|
+
}
|
|
100
|
+
// Install the polyfill bridge once, lazily on first module
|
|
101
|
+
// creation. JSCPolyfillBridge.install is idempotent.
|
|
102
|
+
JSCPolyfillBridge.install(into: JSCRuntime.shared.dispatcherTable)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
Function("hello") {
|
|
106
|
+
"Hello world! 👋"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
AsyncFunction("setValueAsync") { (value: String) in
|
|
110
|
+
self.sendEvent("onChange", [
|
|
111
|
+
"value": value,
|
|
112
|
+
])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
AsyncFunction("requestNavigationPermission") { () -> [String: Any] in
|
|
116
|
+
await withCheckedContinuation { continuation in
|
|
117
|
+
// NavigationManager is @MainActor-isolated; hop onto the main
|
|
118
|
+
// actor before touching it from this nonisolated AsyncFunction.
|
|
119
|
+
Task { @MainActor in
|
|
120
|
+
NavigationManager.shared.requestPermission { accepted in
|
|
121
|
+
continuation.resume(returning: ["ok": true, "accepted": accepted])
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Mapbox has no Terms & Conditions dialog (that was Google-specific),
|
|
128
|
+
// so there's nothing to reset. No-op for parity with Android, whose
|
|
129
|
+
// resetTermsAccepted is also a Mapbox no-op. Returns ok:true since the
|
|
130
|
+
// "reset" is trivially satisfied (no accepted-terms state exists).
|
|
131
|
+
AsyncFunction("resetNavigationPermission") { () -> [String: Any] in
|
|
132
|
+
return ["ok": true]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
AsyncFunction("startNavigation") { (lat: Double, lng: Double, options: [String: Any]?) -> [String: Any] in
|
|
136
|
+
let simulate = options?["simulate"] as? Bool ?? false
|
|
137
|
+
let speedMultiplier = options?["speedMultiplier"] as? Double ?? 1.0
|
|
138
|
+
let mode = options?["mode"] as? String ?? "driving"
|
|
139
|
+
// Opt-in: when > 0, the NavigationManager forces a reroute as
|
|
140
|
+
// soon as the user is this many meters past a pivot they
|
|
141
|
+
// didn't take. nil disables the check entirely.
|
|
142
|
+
let missedTurnRerouteMeters: Double? = {
|
|
143
|
+
if let d = options?["missedTurnRerouteMeters"] as? Double { return d > 0 ? d : nil }
|
|
144
|
+
if let i = options?["missedTurnRerouteMeters"] as? Int { return i > 0 ? Double(i) : nil }
|
|
145
|
+
return nil
|
|
146
|
+
}()
|
|
147
|
+
|
|
148
|
+
var stops: [(lat: Double, lng: Double)] = []
|
|
149
|
+
if let stopsArr = options?["stops"] as? [[String: Double]] {
|
|
150
|
+
stops = stopsArr.compactMap { s in
|
|
151
|
+
guard let slat = s["lat"], let slng = s["lng"] else { return nil }
|
|
152
|
+
return (lat: slat, lng: slng)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if stops.isEmpty { stops = [(lat: lat, lng: lng)] }
|
|
156
|
+
|
|
157
|
+
return await withCheckedContinuation { continuation in
|
|
158
|
+
// NavigationManager is @MainActor-isolated; hop onto the main actor
|
|
159
|
+
// before calling start() from this nonisolated AsyncFunction.
|
|
160
|
+
Task { @MainActor in
|
|
161
|
+
NavigationManager.shared.start(
|
|
162
|
+
stops: stops,
|
|
163
|
+
mode: mode,
|
|
164
|
+
simulate: simulate,
|
|
165
|
+
speedMultiplier: speedMultiplier,
|
|
166
|
+
missedTurnRerouteMeters: missedTurnRerouteMeters,
|
|
167
|
+
onEvent: { [weak self] payload in
|
|
168
|
+
guard let self else { return }
|
|
169
|
+
let kind = payload["kind"] as? String ?? ""
|
|
170
|
+
switch kind {
|
|
171
|
+
case "maneuver": self.sendEvent("onNavManeuver", payload)
|
|
172
|
+
case "rerouting": self.sendEvent("onNavRerouting", payload)
|
|
173
|
+
case "arrived": self.sendEvent("onNavArrived", payload)
|
|
174
|
+
case "off_route": self.sendEvent("onNavOffRoute", payload)
|
|
175
|
+
case "error": self.sendEvent("onNavError", payload)
|
|
176
|
+
default: break
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
onLocation: { [weak self] payload in
|
|
180
|
+
self?.sendEvent("onNavLocation", payload)
|
|
181
|
+
},
|
|
182
|
+
onRoute: { [weak self] payload in
|
|
183
|
+
self?.sendEvent("onNavRoute", payload)
|
|
184
|
+
}
|
|
185
|
+
) { ok, error in
|
|
186
|
+
var result: [String: Any] = ["ok": ok]
|
|
187
|
+
if let error { result["error"] = error }
|
|
188
|
+
continuation.resume(returning: result)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
AsyncFunction("stopNavigation") { () -> [String: Any] in
|
|
195
|
+
await MainActor.run { NavigationManager.shared.stop() }
|
|
196
|
+
return ["ok": true]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
AsyncFunction("simulateDeviation") { (offsetMeters: Double?) -> [String: Any] in
|
|
200
|
+
await MainActor.run { NavigationManager.shared.simulateDeviation(offsetMeters: offsetMeters ?? 50) }
|
|
201
|
+
return ["ok": true]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// iOS doesn't implement the dev toggles yet. Return an explicit
|
|
205
|
+
// error so the JS side can surface "not supported" instead of
|
|
206
|
+
// silently believing the call succeeded.
|
|
207
|
+
AsyncFunction("setWrongSidewalkOffset") { (_: Bool) -> [String: Any] in
|
|
208
|
+
return ["ok": false, "error": "Not implemented on iOS"]
|
|
209
|
+
}
|
|
210
|
+
AsyncFunction("setSkipCrossings") { (_: Bool) -> [String: Any] in
|
|
211
|
+
return ["ok": false, "error": "Not implemented on iOS"]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
AsyncFunction("startHeading") { () -> [String: Any] in
|
|
215
|
+
HeadingManager.shared.start { [weak self] degrees in
|
|
216
|
+
self?.sendEvent("onHeading", ["degrees": degrees])
|
|
217
|
+
}
|
|
218
|
+
return ["ok": true]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
AsyncFunction("stopHeading") { () -> [String: Any] in
|
|
222
|
+
HeadingManager.shared.stop()
|
|
223
|
+
return ["ok": true]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Location:
|
|
227
|
+
|
|
228
|
+
AsyncFunction("showLocationServicesDialog") { () -> Bool in
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
AsyncFunction("openLocationSettings") { () -> Bool in
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
AsyncFunction("openAppSettings") { () -> Bool in
|
|
237
|
+
return false
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
AsyncFunction("openBluetoothSettings") { () -> Bool in
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// MARK: - MentraOS Notification Commands
|
|
245
|
+
|
|
246
|
+
AsyncFunction("setNotificationConfig") { (_: Bool, _: [String]) in
|
|
247
|
+
// No-op on iOS
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
AsyncFunction("getInstalledApps") { () -> [[String: Any]] in
|
|
251
|
+
return []
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
AsyncFunction("getInstalledAppsForNotifications") { () -> [[String: Any]] in
|
|
255
|
+
return []
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
AsyncFunction("hasNotificationListenerPermission") { () -> Bool in
|
|
259
|
+
return false
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
AsyncFunction("openNotificationListenerSettings") { () -> Bool in
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// MARK: - MentraJS Runtime
|
|
267
|
+
|
|
268
|
+
/// Spawn a per-miniapp JS context. Re-spawn is allowed: a live
|
|
269
|
+
/// context with the same packageName is killed first.
|
|
270
|
+
/// Returns true if the polyfill + miniapp source evaluated without
|
|
271
|
+
/// throwing. On failure the context is torn down.
|
|
272
|
+
AsyncFunction("mentraJsSpawn") { (packageName: String, polyfillBundle: String, miniappJs: String) -> Bool in
|
|
273
|
+
return JSCRuntime.shared.spawn(
|
|
274
|
+
packageName: packageName,
|
|
275
|
+
polyfillBundle: polyfillBundle,
|
|
276
|
+
miniappJs: miniappJs
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/// Evaluate an arbitrary script inside the named context. Returns
|
|
281
|
+
/// the JS return value bridged to a JSON-friendly Swift type, or
|
|
282
|
+
/// nil if the context is dead / eval threw. Mostly for dev tooling
|
|
283
|
+
/// + tests; production code paths use mentraJsDispatchToJs.
|
|
284
|
+
AsyncFunction("mentraJsEvaluate") { (packageName: String, source: String) -> Any? in
|
|
285
|
+
return JSCRuntime.shared.evaluate(packageName: packageName, source: source)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// Tear down a JS context. Cancels timers, drops refs, forces GC.
|
|
289
|
+
AsyncFunction("mentraJsKill") { (packageName: String) -> Void in
|
|
290
|
+
JSCRuntime.shared.kill(packageName: packageName)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/// Push an event / response envelope into the named context's
|
|
294
|
+
/// globalThis.__deliver. Used by MentraJSRouter for
|
|
295
|
+
/// glasses-status broadcasts and request/response correlation.
|
|
296
|
+
AsyncFunction("mentraJsDispatchToJs") { (packageName: String, envelope: [String: Any]) -> Void in
|
|
297
|
+
JSCRuntime.shared.dispatchToJs(packageName: packageName, envelope: envelope)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/// Set the installed manifest for a miniapp so the dispatcher's
|
|
301
|
+
/// permission gate can authorize sensitive `__dispatch` calls.
|
|
302
|
+
AsyncFunction("mentraJsSetManifest") { (packageName: String, permissions: [String]) -> Void in
|
|
303
|
+
JSCRuntime.shared.dispatcherTable.setManifest(
|
|
304
|
+
packageName: packageName,
|
|
305
|
+
manifest: InstalledMiniappManifest(permissions: Set(permissions))
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/// Diagnostic: list all live packageNames.
|
|
310
|
+
Function("mentraJsAlivePackages") { () -> [String] in
|
|
311
|
+
return JSCRuntime.shared.alivePackages()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/// Diagnostic: force a JSC garbage collection cycle on the named
|
|
315
|
+
/// context. Used by memory-leak hunts + tests. Returns false when
|
|
316
|
+
/// the context is dead.
|
|
317
|
+
AsyncFunction("mentraJsDebugForceGC") { (packageName: String) -> Bool in
|
|
318
|
+
return JSCRuntime.shared.debugForceGC(packageName: packageName)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/// Read the bundled MentraJS polyfill (startup.js) from the iOS
|
|
322
|
+
/// pod's resource bundle. The host calls this once on app boot,
|
|
323
|
+
/// caches the string, and passes it to every mentraJsSpawn so
|
|
324
|
+
/// every JSContext starts with the same polyfill ABI.
|
|
325
|
+
Function("mentraJsLoadPolyfillBundle") { () -> String in
|
|
326
|
+
return JSCRuntime.loadPolyfillBundle()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// MARK: - Build Environment
|
|
330
|
+
|
|
331
|
+
AsyncFunction("isBetaBuild") { () -> Bool in
|
|
332
|
+
#if targetEnvironment(simulator)
|
|
333
|
+
return false
|
|
334
|
+
#else
|
|
335
|
+
return Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
|
|
336
|
+
#endif
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Configure which screen edges defer iOS system gestures
|
|
340
|
+
// (Control Center, Notification Center, Home indicator). Edge
|
|
341
|
+
// strings: "top", "bottom", "left", "right", "all". An empty
|
|
342
|
+
// array restores default behavior. iOS-only; Android is a no-op.
|
|
343
|
+
AsyncFunction("setDeferredSystemGestures") { (edges: [String]) -> Void in
|
|
344
|
+
var rect: UIRectEdge = []
|
|
345
|
+
for edge in edges {
|
|
346
|
+
switch edge.lowercased() {
|
|
347
|
+
case "top": rect.insert(.top)
|
|
348
|
+
case "bottom": rect.insert(.bottom)
|
|
349
|
+
case "left": rect.insert(.left)
|
|
350
|
+
case "right": rect.insert(.right)
|
|
351
|
+
case "all": rect = .all
|
|
352
|
+
default: break
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
SystemGestures.setDeferredEdges(rect)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
Function("showAVRoutePicker") { (tintColor: String?) in
|
|
359
|
+
DispatchQueue.main.async {
|
|
360
|
+
let picker = AVRoutePickerView()
|
|
361
|
+
picker.prioritizesVideoDevices = false
|
|
362
|
+
|
|
363
|
+
if let colorString = tintColor {
|
|
364
|
+
picker.tintColor = UIColor(hexString: colorString)
|
|
365
|
+
} else {
|
|
366
|
+
picker.tintColor = .label
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if let button = picker.subviews.first(where: { $0 is UIButton }) as? UIButton {
|
|
370
|
+
button.sendActions(for: .touchUpInside)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
View(CrustView.self) {
|
|
376
|
+
Prop("url") { (view: CrustView, url: URL) in
|
|
377
|
+
if view.webView.url != url {
|
|
378
|
+
view.webView.load(URLRequest(url: url))
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
Events("onLoad")
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// MARK: - Image Processing Commands
|
|
386
|
+
|
|
387
|
+
AsyncFunction("processGalleryImage") {
|
|
388
|
+
(inputPath: String, outputPath: String, options: [String: Any]) -> [String: Any] in
|
|
389
|
+
let lensCorrection = options["lensCorrection"] as? Bool ?? true
|
|
390
|
+
let colorCorrection = options["colorCorrection"] as? Bool ?? true
|
|
391
|
+
|
|
392
|
+
guard FileManager.default.fileExists(atPath: inputPath) else {
|
|
393
|
+
return ["success": false, "error": "Input file does not exist"]
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let processingTimeMs = ImageProcessor.process(
|
|
397
|
+
inputPath: inputPath,
|
|
398
|
+
outputPath: outputPath,
|
|
399
|
+
lensCorrection: lensCorrection,
|
|
400
|
+
colorCorrection: colorCorrection
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if processingTimeMs >= 0 {
|
|
404
|
+
return [
|
|
405
|
+
"success": true,
|
|
406
|
+
"outputPath": outputPath,
|
|
407
|
+
"processingTimeMs": processingTimeMs,
|
|
408
|
+
]
|
|
409
|
+
} else {
|
|
410
|
+
return ["success": false, "error": "Processing failed"]
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// MARK: - HDR Merge Commands
|
|
415
|
+
|
|
416
|
+
AsyncFunction("mergeHdrBrackets") {
|
|
417
|
+
(underPath: String, normalPath: String, overPath: String, outputPath: String)
|
|
418
|
+
-> [String: Any] in
|
|
419
|
+
let processingTimeMs = ImageProcessor.mergeHdr(
|
|
420
|
+
underPath: underPath,
|
|
421
|
+
normalPath: normalPath,
|
|
422
|
+
overPath: overPath,
|
|
423
|
+
outputPath: outputPath
|
|
424
|
+
)
|
|
425
|
+
if processingTimeMs >= 0 {
|
|
426
|
+
return [
|
|
427
|
+
"success": true,
|
|
428
|
+
"outputPath": outputPath,
|
|
429
|
+
"processingTimeMs": processingTimeMs,
|
|
430
|
+
]
|
|
431
|
+
} else {
|
|
432
|
+
return ["success": false, "error": "HDR merge failed"]
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// MARK: - Video Stabilization Commands
|
|
437
|
+
|
|
438
|
+
AsyncFunction("stabilizeVideo") {
|
|
439
|
+
(inputPath: String, imuPath: String, outputPath: String) -> [String: Any] in
|
|
440
|
+
guard FileManager.default.fileExists(atPath: inputPath) else {
|
|
441
|
+
return ["success": false, "error": "Input video does not exist"]
|
|
442
|
+
}
|
|
443
|
+
guard FileManager.default.fileExists(atPath: imuPath) else {
|
|
444
|
+
return ["success": false, "error": "IMU sidecar does not exist"]
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let processingTimeMs = VideoStabilizer.stabilize(
|
|
448
|
+
inputPath: inputPath,
|
|
449
|
+
imuPath: imuPath,
|
|
450
|
+
outputPath: outputPath
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
if processingTimeMs >= 0 {
|
|
454
|
+
return [
|
|
455
|
+
"success": true,
|
|
456
|
+
"outputPath": outputPath,
|
|
457
|
+
"processingTimeMs": processingTimeMs,
|
|
458
|
+
]
|
|
459
|
+
} else {
|
|
460
|
+
return ["success": false, "error": "Stabilization failed"]
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// MARK: - Media Library Commands
|
|
465
|
+
|
|
466
|
+
AsyncFunction("saveToGalleryWithDate") {
|
|
467
|
+
(
|
|
468
|
+
filePath: String,
|
|
469
|
+
captureTimeMillis: Int64?,
|
|
470
|
+
displayName: String?
|
|
471
|
+
) -> [String: Any] in
|
|
472
|
+
let fileURL = URL(fileURLWithPath: filePath)
|
|
473
|
+
|
|
474
|
+
guard FileManager.default.fileExists(atPath: filePath) else {
|
|
475
|
+
return ["success": false, "error": "File does not exist"]
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let pathExtension = fileURL.pathExtension.lowercased()
|
|
479
|
+
let isVideo = ["mp4", "mov", "avi", "m4v"].contains(pathExtension)
|
|
480
|
+
let assetFileName = displayName?.isEmpty == false
|
|
481
|
+
? displayName!
|
|
482
|
+
: fileURL.lastPathComponent
|
|
483
|
+
let captureDate = captureTimeMillis.map {
|
|
484
|
+
Date(timeIntervalSince1970: TimeInterval($0) / 1000.0)
|
|
485
|
+
}
|
|
486
|
+
let candidateFileNames = [assetFileName, fileURL.lastPathComponent]
|
|
487
|
+
.filter { !$0.isEmpty }
|
|
488
|
+
let stableFileName = assetFileName.hasPrefix("IMG_") || assetFileName.hasPrefix("VID_")
|
|
489
|
+
? assetFileName
|
|
490
|
+
: nil
|
|
491
|
+
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: filePath)
|
|
492
|
+
let expectedFileSize = (fileAttributes?[.size] as? NSNumber)?.int64Value
|
|
493
|
+
let authorizationStatus: PHAuthorizationStatus
|
|
494
|
+
if #available(iOS 14, *) {
|
|
495
|
+
authorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
|
496
|
+
} else {
|
|
497
|
+
authorizationStatus = PHPhotoLibrary.authorizationStatus()
|
|
498
|
+
}
|
|
499
|
+
let hasLimitedAccess: Bool
|
|
500
|
+
if #available(iOS 14, *) {
|
|
501
|
+
hasLimitedAccess = authorizationStatus == .limited
|
|
502
|
+
} else {
|
|
503
|
+
hasLimitedAccess = false
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// The JS ledger normally supplies the previous PhotoKit receipt. If the app was
|
|
507
|
+
// terminated after Photos committed but before that receipt was persisted, reconcile
|
|
508
|
+
// by our stable capture filename/date before creating another asset.
|
|
509
|
+
if let existingIdentifier = await self.findExistingGalleryAssetIdentifier(
|
|
510
|
+
fileNames: candidateFileNames,
|
|
511
|
+
stableFileName: stableFileName,
|
|
512
|
+
expectedFileSize: expectedFileSize,
|
|
513
|
+
captureDate: captureDate,
|
|
514
|
+
isVideo: isVideo
|
|
515
|
+
) {
|
|
516
|
+
NSLog("CrustModule: Reusing existing gallery asset")
|
|
517
|
+
return [
|
|
518
|
+
"success": true,
|
|
519
|
+
"identifier": existingIdentifier,
|
|
520
|
+
"existing": true,
|
|
521
|
+
]
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let saveResult = await self.createGalleryAsset(
|
|
525
|
+
fileURL: fileURL,
|
|
526
|
+
assetFileName: assetFileName,
|
|
527
|
+
captureDate: captureDate,
|
|
528
|
+
isVideo: isVideo,
|
|
529
|
+
hasLimitedAccess: hasLimitedAccess
|
|
530
|
+
)
|
|
531
|
+
if saveResult.timedOut {
|
|
532
|
+
return [
|
|
533
|
+
"success": false,
|
|
534
|
+
"error": "Photo library save timed out; retry will reconcile the result",
|
|
535
|
+
]
|
|
536
|
+
}
|
|
537
|
+
if saveResult.creationFailed {
|
|
538
|
+
return ["success": false, "error": "Failed to create PhotoKit asset placeholder"]
|
|
539
|
+
}
|
|
540
|
+
if let errorMessage = saveResult.errorMessage {
|
|
541
|
+
NSLog("CrustModule: Error saving to gallery: \(errorMessage)")
|
|
542
|
+
return ["success": false, "error": errorMessage]
|
|
543
|
+
}
|
|
544
|
+
guard saveResult.succeeded else {
|
|
545
|
+
return ["success": false, "error": "Photo library did not commit the asset"]
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
NSLog("CrustModule: Successfully saved to gallery with proper creation date")
|
|
549
|
+
return ["success": true, "identifier": saveResult.assetIdentifier ?? ""]
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private func createGalleryAsset(
|
|
554
|
+
fileURL: URL,
|
|
555
|
+
assetFileName: String,
|
|
556
|
+
captureDate: Date?,
|
|
557
|
+
isVideo: Bool,
|
|
558
|
+
hasLimitedAccess: Bool
|
|
559
|
+
) async -> PhotoLibrarySaveResult {
|
|
560
|
+
await withCheckedContinuation { continuation in
|
|
561
|
+
let gate = CheckedContinuationGate(continuation)
|
|
562
|
+
let state = PhotoLibrarySaveState()
|
|
563
|
+
|
|
564
|
+
PHPhotoLibrary.shared().performChanges {
|
|
565
|
+
let creationRequest = PHAssetCreationRequest.forAsset()
|
|
566
|
+
let resourceOptions = PHAssetResourceCreationOptions()
|
|
567
|
+
resourceOptions.originalFilename = assetFileName
|
|
568
|
+
creationRequest.addResource(
|
|
569
|
+
with: isVideo ? .video : .photo,
|
|
570
|
+
fileURL: fileURL,
|
|
571
|
+
options: resourceOptions
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
if let captureDate {
|
|
575
|
+
creationRequest.creationDate = captureDate
|
|
576
|
+
NSLog("CrustModule: Setting creation date to: \(captureDate)")
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
guard let assetPlaceholder = creationRequest.placeholderForCreatedAsset else {
|
|
580
|
+
NSLog("CrustModule: Missing placeholder for created asset")
|
|
581
|
+
state.markCreationFailed()
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
state.setAssetIdentifier(assetPlaceholder.localIdentifier)
|
|
585
|
+
|
|
586
|
+
// Limited-library access permits creating an asset, but does not reliably
|
|
587
|
+
// permit enumerating or mutating arbitrary user albums. Save to the camera
|
|
588
|
+
// roll and skip Mentra album mutation in that mode.
|
|
589
|
+
guard !hasLimitedAccess else { return }
|
|
590
|
+
|
|
591
|
+
let albumFetch = PHFetchOptions()
|
|
592
|
+
albumFetch.predicate = NSPredicate(
|
|
593
|
+
format: "localizedTitle == %@", MentraSyncedMediaAlbum.localizedTitle
|
|
594
|
+
)
|
|
595
|
+
albumFetch.fetchLimit = 1
|
|
596
|
+
let existingAlbums = PHAssetCollection.fetchAssetCollections(
|
|
597
|
+
with: .album,
|
|
598
|
+
subtype: .albumRegular,
|
|
599
|
+
options: albumFetch
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
if let album = existingAlbums.firstObject,
|
|
603
|
+
let albumChange = PHAssetCollectionChangeRequest(for: album)
|
|
604
|
+
{
|
|
605
|
+
albumChange.addAssets([assetPlaceholder] as NSArray)
|
|
606
|
+
} else if existingAlbums.firstObject == nil {
|
|
607
|
+
let newAlbumChange = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(
|
|
608
|
+
withTitle: MentraSyncedMediaAlbum.localizedTitle
|
|
609
|
+
)
|
|
610
|
+
newAlbumChange.addAssets([assetPlaceholder] as NSArray)
|
|
611
|
+
} else {
|
|
612
|
+
NSLog(
|
|
613
|
+
"CrustModule: Mentra album exists but is not writable; asset saved to library only"
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
} completionHandler: { succeeded, error in
|
|
617
|
+
let snapshot = state.snapshot()
|
|
618
|
+
gate.resume(returning: PhotoLibrarySaveResult(
|
|
619
|
+
succeeded: succeeded,
|
|
620
|
+
assetIdentifier: snapshot.assetIdentifier,
|
|
621
|
+
creationFailed: snapshot.creationFailed,
|
|
622
|
+
errorMessage: error?.localizedDescription,
|
|
623
|
+
timedOut: false
|
|
624
|
+
))
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 120) {
|
|
628
|
+
let snapshot = state.snapshot()
|
|
629
|
+
gate.resume(returning: PhotoLibrarySaveResult(
|
|
630
|
+
succeeded: false,
|
|
631
|
+
assetIdentifier: snapshot.assetIdentifier,
|
|
632
|
+
creationFailed: snapshot.creationFailed,
|
|
633
|
+
errorMessage: nil,
|
|
634
|
+
timedOut: true
|
|
635
|
+
))
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private func findExistingGalleryAssetIdentifier(
|
|
641
|
+
fileNames: [String],
|
|
642
|
+
stableFileName: String?,
|
|
643
|
+
expectedFileSize: Int64?,
|
|
644
|
+
captureDate: Date?,
|
|
645
|
+
isVideo: Bool
|
|
646
|
+
) async -> String? {
|
|
647
|
+
guard !fileNames.isEmpty else { return nil }
|
|
648
|
+
let options = PHFetchOptions()
|
|
649
|
+
let mediaType = isVideo ? PHAssetMediaType.video : PHAssetMediaType.image
|
|
650
|
+
if let captureDate {
|
|
651
|
+
options.predicate = NSPredicate(
|
|
652
|
+
format: "mediaType == %d AND creationDate >= %@ AND creationDate <= %@",
|
|
653
|
+
mediaType.rawValue,
|
|
654
|
+
captureDate.addingTimeInterval(-1) as NSDate,
|
|
655
|
+
captureDate.addingTimeInterval(1) as NSDate
|
|
656
|
+
)
|
|
657
|
+
} else {
|
|
658
|
+
options.predicate = NSPredicate(format: "mediaType == %d", mediaType.rawValue)
|
|
659
|
+
options.fetchLimit = 200
|
|
660
|
+
}
|
|
661
|
+
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
|
662
|
+
|
|
663
|
+
let assets = PHAsset.fetchAssets(with: options)
|
|
664
|
+
var stableCandidates: [PHAsset] = []
|
|
665
|
+
var legacyCandidates: [PHAsset] = []
|
|
666
|
+
assets.enumerateObjects { asset, _, _ in
|
|
667
|
+
let resources = PHAssetResource.assetResources(for: asset)
|
|
668
|
+
let stableMatch = stableFileName.map { stableName in
|
|
669
|
+
resources.contains { $0.originalFilename == stableName }
|
|
670
|
+
} ?? false
|
|
671
|
+
if stableMatch {
|
|
672
|
+
stableCandidates.append(asset)
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
let legacyNameMatch = resources.contains {
|
|
676
|
+
guard fileNames.contains($0.originalFilename), $0.originalFilename != stableFileName else {
|
|
677
|
+
return false
|
|
678
|
+
}
|
|
679
|
+
return true
|
|
680
|
+
}
|
|
681
|
+
if legacyNameMatch { legacyCandidates.append(asset) }
|
|
682
|
+
}
|
|
683
|
+
guard let expectedFileSize else { return nil }
|
|
684
|
+
// PhotoKit's original-resource lookups are asynchronous. Resolve candidates after
|
|
685
|
+
// enumeration so the fetch callback never blocks the Photos framework. Stable names
|
|
686
|
+
// may repeat across a restored/older library; require the generation's exact byte size
|
|
687
|
+
// and reuse the newest matching completed asset.
|
|
688
|
+
for asset in stableCandidates {
|
|
689
|
+
if await self.assetOriginalFile(asset, matchesByteCount: expectedFileSize) {
|
|
690
|
+
return asset.localIdentifier
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
var legacyIdentifiers: [String] = []
|
|
695
|
+
for asset in legacyCandidates {
|
|
696
|
+
if await self.assetOriginalFile(asset, matchesByteCount: expectedFileSize) {
|
|
697
|
+
legacyIdentifiers.append(asset.localIdentifier)
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Legacy base names remain safe only when name + capture time + media type + exact
|
|
701
|
+
// original byte size identify one asset.
|
|
702
|
+
return legacyIdentifiers.count == 1 ? legacyIdentifiers[0] : nil
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/// PhotoKit does not expose resource byte size on the deployment targets we support.
|
|
706
|
+
/// Inspect a video's local original URL or an image's original bytes asynchronously.
|
|
707
|
+
/// Network access stays disabled: if the original is only in iCloud, reconciliation fails
|
|
708
|
+
/// closed and the caller creates a fresh local-library asset instead of risking data loss.
|
|
709
|
+
private func assetOriginalFile(
|
|
710
|
+
_ asset: PHAsset,
|
|
711
|
+
matchesByteCount expectedByteCount: Int64
|
|
712
|
+
) async -> Bool {
|
|
713
|
+
let manager = PHImageManager.default()
|
|
714
|
+
if asset.mediaType == .video {
|
|
715
|
+
let options = PHVideoRequestOptions()
|
|
716
|
+
options.isNetworkAccessAllowed = false
|
|
717
|
+
options.version = .original
|
|
718
|
+
return await withCheckedContinuation { continuation in
|
|
719
|
+
let gate = CheckedContinuationGate(continuation)
|
|
720
|
+
let requestID = manager.requestAVAsset(forVideo: asset, options: options) { avAsset, _, _ in
|
|
721
|
+
guard let originalURL = (avAsset as? AVURLAsset)?.url,
|
|
722
|
+
let attributes = try? FileManager.default.attributesOfItem(atPath: originalURL.path),
|
|
723
|
+
let byteCount = attributes[.size] as? NSNumber else {
|
|
724
|
+
gate.resume(returning: false)
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
gate.resume(returning: byteCount.int64Value == expectedByteCount)
|
|
728
|
+
}
|
|
729
|
+
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 30) {
|
|
730
|
+
manager.cancelImageRequest(requestID)
|
|
731
|
+
gate.resume(returning: false)
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
let options = PHImageRequestOptions()
|
|
737
|
+
options.isNetworkAccessAllowed = false
|
|
738
|
+
options.version = .original
|
|
739
|
+
options.deliveryMode = .highQualityFormat
|
|
740
|
+
return await withCheckedContinuation { continuation in
|
|
741
|
+
let gate = CheckedContinuationGate(continuation)
|
|
742
|
+
let requestID = manager.requestImageDataAndOrientation(for: asset, options: options) { data, _, _, info in
|
|
743
|
+
if info?[PHImageResultIsDegradedKey] as? Bool == true { return }
|
|
744
|
+
gate.resume(returning: data.map { Int64($0.count) == expectedByteCount } ?? false)
|
|
745
|
+
}
|
|
746
|
+
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 30) {
|
|
747
|
+
manager.cancelImageRequest(requestID)
|
|
748
|
+
gate.resume(returning: false)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
extension UIColor {
|
|
755
|
+
convenience init?(hexString: String) {
|
|
756
|
+
var hex = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
757
|
+
hex = hex.replacingOccurrences(of: "#", with: "")
|
|
758
|
+
|
|
759
|
+
var rgb: UInt64 = 0
|
|
760
|
+
guard Scanner(string: hex).scanHexInt64(&rgb) else { return nil }
|
|
761
|
+
|
|
762
|
+
let length = hex.count
|
|
763
|
+
let r, g, b, a: CGFloat
|
|
764
|
+
|
|
765
|
+
if length == 6 {
|
|
766
|
+
r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
|
|
767
|
+
g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
|
|
768
|
+
b = CGFloat(rgb & 0x0000FF) / 255.0
|
|
769
|
+
a = 1.0
|
|
770
|
+
} else if length == 8 {
|
|
771
|
+
r = CGFloat((rgb & 0xFF00_0000) >> 24) / 255.0
|
|
772
|
+
g = CGFloat((rgb & 0x00FF_0000) >> 16) / 255.0
|
|
773
|
+
b = CGFloat((rgb & 0x0000_FF00) >> 8) / 255.0
|
|
774
|
+
a = CGFloat(rgb & 0x0000_00FF) / 255.0
|
|
775
|
+
} else {
|
|
776
|
+
return nil
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
self.init(red: r, green: g, blue: b, alpha: a)
|
|
780
|
+
}
|
|
781
|
+
}
|