@mentra/crust 0.1.0-dev.0 → 0.1.0-dev.1

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 CHANGED
@@ -1,35 +1,41 @@
1
- # crust
1
+ # @mentra/crust
2
2
 
3
- Mentra Native Module
3
+ The MentraOS native runtime layer: an [Expo module](https://docs.expo.dev/modules/overview/)
4
+ providing the native capabilities the Mentra Engine's miniapp runtime sits on —
5
+ per-miniapp JS contexts (QuickJS on Android, JavaScriptCore on iOS), the
6
+ native side of the MentraJS bridge, navigation, and device utilities.
4
7
 
5
- # API documentation
8
+ You don't call crust directly from app code: it's a **peer dependency of
9
+ [`@mentra/engine`](https://www.npmjs.com/package/@mentra/engine)**. A host app
10
+ embedding the engine installs crust alongside it and Expo autolinking picks it
11
+ up.
6
12
 
7
- - [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/crust/)
8
- - [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/crust/)
13
+ ## Install
9
14
 
10
- # Installation in managed Expo projects
11
-
12
- For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release.
13
-
14
- # Installation in bare React Native projects
15
-
16
- For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
17
-
18
- ### Add the package to your npm dependencies
19
-
20
- ```
21
- npm install crust
15
+ ```sh
16
+ npm install @mentra/crust@dev
22
17
  ```
23
18
 
24
- ### Configure for Android
19
+ > Currently published on the `dev` dist-tag (prerelease channel).
25
20
 
21
+ ## Config plugin
26
22
 
23
+ The package ships an Expo config plugin (`app.plugin.js`) that carries its
24
+ Android build contract — Mapbox's maven repository, protobuf exclusions, and
25
+ core-library desugaring. Add it to the host app's Expo config:
27
26
 
27
+ ```json
28
+ {"expo": {"plugins": ["@mentra/crust"]}}
29
+ ```
28
30
 
29
- ### Configure for iOS
31
+ Building with the navigation feature requires a `MAPBOX_DOWNLOADS_TOKEN` in
32
+ the Android build environment (Mapbox's SDK repository is authenticated).
30
33
 
31
- Run `npx pod-install` after installing the npm package.
34
+ At build time the Android side also reads the MentraJS polyfill bundle from
35
+ its [`@mentra/jspolyfill`](https://www.npmjs.com/package/@mentra/jspolyfill)
36
+ sibling, which is declared as a dependency.
32
37
 
33
- # Contributing
38
+ ## Part of MentraOS
34
39
 
35
- Contributions are very welcome! Please refer to guidelines described in the [contributing guide]( https://github.com/expo/expo#contributing).
40
+ Source lives in the [MentraOS monorepo](https://github.com/Mentra-Community/MentraOS)
41
+ under `mobile/modules/crust`. Issues and contributions welcome there.
@@ -129,6 +129,17 @@ class CrustModule : Module() {
129
129
  sendEvent("onChange", mapOf("value" to value))
130
130
  }
131
131
 
132
+ AsyncFunction("nativeHttpRequest") {
133
+ method: String, url: String, headers: Map<String, String>, body: String? ->
134
+ val result = JSCPolyfillBridge.executeHttp(method, url, headers, body)
135
+ mapOf(
136
+ "status" to result.status,
137
+ "statusText" to result.statusText,
138
+ "headers" to result.headers,
139
+ "body" to result.body,
140
+ )
141
+ }
142
+
132
143
  Function("showAVRoutePicker") { _: String? ->
133
144
  // iOS-only; Android uses system Bluetooth settings / Crust where appropriate.
134
145
  }
@@ -575,6 +586,55 @@ class CrustModule : Module() {
575
586
  }
576
587
  }
577
588
 
589
+ val relativePath =
590
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
591
+ if (isVideo) "Movies/Mentra" else "Pictures/Mentra"
592
+ } else {
593
+ null
594
+ }
595
+ val resolver = context.contentResolver
596
+ val stableDisplayName =
597
+ mediaDisplayName.takeIf {
598
+ it.startsWith("IMG_") || it.startsWith("VID_")
599
+ }
600
+ val existingUri =
601
+ stableDisplayName?.let {
602
+ findExistingGalleryAsset(
603
+ resolver,
604
+ collection,
605
+ listOf(it),
606
+ file.length(),
607
+ captureTimeMillis,
608
+ relativePath,
609
+ context.packageName,
610
+ requireUniqueMatch = false,
611
+ )
612
+ }
613
+ ?: findExistingGalleryAsset(
614
+ resolver,
615
+ collection,
616
+ listOfNotNull(
617
+ mediaDisplayName.takeIf { stableDisplayName == null },
618
+ file.name.takeIf {
619
+ it.isNotBlank() && it != stableDisplayName
620
+ },
621
+ )
622
+ .distinct(),
623
+ file.length(),
624
+ captureTimeMillis,
625
+ relativePath,
626
+ context.packageName,
627
+ requireUniqueMatch = true,
628
+ )
629
+ if (existingUri != null) {
630
+ android.util.Log.d("CrustModule", "Reusing existing gallery asset")
631
+ return@AsyncFunction mapOf(
632
+ "success" to true,
633
+ "uri" to existingUri.toString(),
634
+ "existing" to true,
635
+ )
636
+ }
637
+
578
638
  val values =
579
639
  android.content.ContentValues().apply {
580
640
  put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, mediaDisplayName)
@@ -593,19 +653,12 @@ class CrustModule : Module() {
593
653
  )
594
654
  }
595
655
 
596
- if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
597
- val relativePath =
598
- if (isVideo) {
599
- "Movies/Mentra"
600
- } else {
601
- "Pictures/Mentra"
602
- }
656
+ if (relativePath != null) {
603
657
  put(android.provider.MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
604
658
  put(android.provider.MediaStore.MediaColumns.IS_PENDING, 1)
605
659
  }
606
660
  }
607
661
 
608
- val resolver = context.contentResolver
609
662
  val uri =
610
663
  resolver.insert(collection, values)
611
664
  ?: throw IllegalStateException("Failed to create MediaStore entry")
@@ -879,4 +932,111 @@ class CrustModule : Module() {
879
932
  mapOf("ok" to true)
880
933
  }
881
934
  }
935
+
936
+ /**
937
+ * Find a completed export from a previous attempt. A crash can happen after MediaStore commits
938
+ * but before JavaScript persists the URI receipt; the stable capture display name, size, and
939
+ * capture time, and Mentra album path let the retry return that receipt instead of inserting a
940
+ * duplicate. Scoping by album is also important before deleting interrupted pending rows: a
941
+ * same-named asset owned by another album must never be treated as ours.
942
+ */
943
+ private fun findExistingGalleryAsset(
944
+ resolver: android.content.ContentResolver,
945
+ collection: android.net.Uri,
946
+ displayNames: List<String>,
947
+ size: Long,
948
+ captureTimeMillis: Long?,
949
+ relativePath: String?,
950
+ ownerPackageName: String,
951
+ requireUniqueMatch: Boolean,
952
+ ): android.net.Uri? {
953
+ val dateColumn = android.provider.MediaStore.Images.ImageColumns.DATE_TAKEN
954
+ if (displayNames.isEmpty()) return null
955
+ val namePlaceholders = displayNames.joinToString(",") { "?" }
956
+ val selectionParts =
957
+ mutableListOf(
958
+ "${android.provider.MediaStore.MediaColumns.DISPLAY_NAME} IN ($namePlaceholders)",
959
+ )
960
+ val selectionArgs = displayNames.toMutableList()
961
+ if (captureTimeMillis != null) {
962
+ selectionParts.add("$dateColumn = ?")
963
+ selectionArgs.add(captureTimeMillis.toString())
964
+ }
965
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q &&
966
+ relativePath != null
967
+ ) {
968
+ val pathWithoutTrailingSlash = relativePath.trimEnd('/')
969
+ selectionParts.add(
970
+ "(${android.provider.MediaStore.MediaColumns.RELATIVE_PATH} = ? OR " +
971
+ "${android.provider.MediaStore.MediaColumns.RELATIVE_PATH} = ?)"
972
+ )
973
+ // MediaProvider normally canonicalizes RELATIVE_PATH with a trailing slash. Accept the
974
+ // caller's original representation too so exports created by older Android builds remain
975
+ // reconcilable, while still requiring an exact Mentra album match.
976
+ selectionArgs.add(pathWithoutTrailingSlash)
977
+ selectionArgs.add("$pathWithoutTrailingSlash/")
978
+ // Never reconcile or delete another application's pending MediaStore row.
979
+ selectionParts.add("${android.provider.MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?")
980
+ selectionArgs.add(ownerPackageName)
981
+ }
982
+ val projection =
983
+ mutableListOf(
984
+ android.provider.BaseColumns._ID,
985
+ android.provider.MediaStore.MediaColumns.SIZE,
986
+ )
987
+ .apply {
988
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
989
+ add(android.provider.MediaStore.MediaColumns.IS_PENDING)
990
+ }
991
+ }
992
+
993
+ return try {
994
+ resolver
995
+ .query(
996
+ collection,
997
+ projection.toTypedArray(),
998
+ selectionParts.joinToString(" AND "),
999
+ selectionArgs.toTypedArray(),
1000
+ "${android.provider.BaseColumns._ID} DESC",
1001
+ )
1002
+ ?.use { cursor ->
1003
+ val candidates = mutableListOf<android.net.Uri>()
1004
+ val idColumn = cursor.getColumnIndexOrThrow(android.provider.BaseColumns._ID)
1005
+ val sizeColumn =
1006
+ cursor.getColumnIndexOrThrow(android.provider.MediaStore.MediaColumns.SIZE)
1007
+ val pendingColumn =
1008
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
1009
+ cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.IS_PENDING)
1010
+ } else {
1011
+ -1
1012
+ }
1013
+ while (cursor.moveToNext()) {
1014
+ val uri =
1015
+ android.content.ContentUris.withAppendedId(
1016
+ collection,
1017
+ cursor.getLong(idColumn),
1018
+ )
1019
+ if (pendingColumn >= 0 && cursor.getInt(pendingColumn) != 0) {
1020
+ // This app owns the row and it was never published. Remove the interrupted
1021
+ // placeholder before retrying the copy, even if it contains only a prefix.
1022
+ resolver.delete(uri, null, null)
1023
+ continue
1024
+ }
1025
+ if (cursor.getLong(sizeColumn) == size) candidates.add(uri)
1026
+ }
1027
+ if (requireUniqueMatch) {
1028
+ // A legacy `base.jpg`/`base.mp4` match is safe only when the complete
1029
+ // name/date/size/path/owner fingerprint identifies exactly one asset.
1030
+ candidates.singleOrNull()
1031
+ } else {
1032
+ // Capture-derived display names are stable. If an older retry already made
1033
+ // duplicates, reuse the newest completed row instead of creating another.
1034
+ candidates.firstOrNull()
1035
+ }
1036
+ }
1037
+ } catch (error: Exception) {
1038
+ android.util.Log.w("CrustModule", "Unable to reconcile existing gallery asset", error)
1039
+ null
1040
+ }
1041
+ }
882
1042
  }
@@ -45,6 +45,41 @@ object JSCPolyfillBridge {
45
45
  .build()
46
46
  }
47
47
 
48
+ data class HttpResult(
49
+ val status: Int,
50
+ val statusText: String,
51
+ val headers: Map<String, String>,
52
+ val body: String,
53
+ )
54
+
55
+ /** Shared OkHttp execution path for host cloud-client requests. */
56
+ fun executeHttp(method: String, url: String, headers: Map<String, String>, bodyString: String?): HttpResult {
57
+ val builder = Request.Builder().url(url)
58
+ for ((name, value) in headers) builder.header(name, value)
59
+ val contentType = headers.entries
60
+ .firstOrNull { (name, _) -> name.equals("content-type", ignoreCase = true) }
61
+ ?.value
62
+ val upperMethod = method.uppercase()
63
+ val requestBody = when {
64
+ bodyString != null -> bodyString.toRequestBody((contentType ?: "application/octet-stream").toMediaTypeOrNull())
65
+ upperMethod == "POST" || upperMethod == "PUT" || upperMethod == "PATCH" -> "".toRequestBody(null)
66
+ else -> null
67
+ }
68
+ builder.method(upperMethod, requestBody)
69
+ httpClient.newCall(builder.build()).execute().use { response ->
70
+ val responseHeaders = mutableMapOf<String, String>()
71
+ for (name in response.headers.names()) {
72
+ responseHeaders[name.lowercase()] = response.headers.values(name).joinToString(", ")
73
+ }
74
+ return HttpResult(
75
+ status = response.code,
76
+ statusText = response.message,
77
+ headers = responseHeaders,
78
+ body = response.body?.string() ?: "",
79
+ )
80
+ }
81
+ }
82
+
48
83
  /** Idempotent. Call once on host boot, after the dispatcher is created. */
49
84
  fun install(runtime: JSCRuntime) {
50
85
  installFetch(runtime)
@@ -187,8 +222,14 @@ object JSCPolyfillBridge {
187
222
 
188
223
  val builder = Request.Builder().url(url)
189
224
  for ((k, v) in headers) builder.header(k, v)
225
+ // HTTP header names are case-insensitive. The JS fetch caller will
226
+ // commonly provide `Content-Type`; a direct lowercase map lookup
227
+ // misses that value and causes OkHttp to emit application/octet-stream.
228
+ val contentType = headers.entries
229
+ .firstOrNull { (name, _) -> name.equals("content-type", ignoreCase = true) }
230
+ ?.value
190
231
  val body = if (bodyString.isNullOrEmpty()) null else bodyString.toRequestBody(
191
- (headers["content-type"] ?: "application/octet-stream").toMediaTypeOrNull()
232
+ (contentType ?: "application/octet-stream").toMediaTypeOrNull()
192
233
  )
193
234
  builder.method(method.uppercase(), body)
194
235
 
@@ -4,6 +4,12 @@ declare class CrustModule extends NativeModule<CrustModuleEvents> {
4
4
  PI: number;
5
5
  hello(): string;
6
6
  setValueAsync(value: string): Promise<void>;
7
+ nativeHttpRequest(method: string, url: string, headers: Record<string, string>, body?: string | null): Promise<{
8
+ status: number;
9
+ statusText: string;
10
+ headers: Record<string, string>;
11
+ body: string;
12
+ }>;
7
13
  showAVRoutePicker(tintColor?: string | null): void;
8
14
  /**
9
15
  * iOS: configure `preferredScreenEdgesDeferringSystemGestures`. When an
@@ -51,6 +57,7 @@ declare class CrustModule extends NativeModule<CrustModuleEvents> {
51
57
  success: boolean;
52
58
  uri?: string;
53
59
  identifier?: string;
60
+ existing?: boolean;
54
61
  error?: string;
55
62
  }>;
56
63
  startNavigation(lat: number, lng: number, options?: {
@@ -1 +1 @@
1
- {"version":3,"file":"CrustModule.d.ts","sourceRoot":"","sources":["../src/CrustModule.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"CrustModule.d.ts","sourceRoot":"","sources":["../src/CrustModule.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"CrustModule.js","sourceRoot":"","sources":["../src/CrustModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,mBAAmB,EAAC,MAAM,MAAM,CAAA;AAwLtD,yDAAyD;AACzD,eAAe,mBAAmB,CAAc,OAAO,CAAC,CAAA","sourcesContent":["import {NativeModule, requireNativeModule} from \"expo\"\n\nimport {CrustModuleEvents, InstalledApp} from \"./Crust.types\"\n\ndeclare class CrustModule extends NativeModule<CrustModuleEvents> {\n PI: number\n hello(): string\n setValueAsync(value: string): Promise<void>\n showAVRoutePicker(tintColor?: string | null): void\n\n /**\n * iOS: configure `preferredScreenEdgesDeferringSystemGestures`. When an\n * edge is deferred, the first swipe across that edge is consumed by the\n * app and the system gesture (Control Center, Notification Center, Home\n * indicator) only fires on a second swipe — i.e. a two-swipe-to-exit UX.\n *\n * Pass `[]` to restore default behavior. Android: no-op (Android has no\n * per-app equivalent; system gestures are configured at the OS level).\n */\n setDeferredSystemGestures(\n edges: Array<\"top\" | \"bottom\" | \"left\" | \"right\" | \"all\">,\n ): Promise<void>\n\n // MentraOS Notification Commands\n setNotificationConfig(enabled: boolean, blocklist: string[]): Promise<void>\n getInstalledApps(): Promise<InstalledApp[]>\n getInstalledAppsForNotifications(): Promise<InstalledApp[]>\n hasNotificationListenerPermission(): Promise<boolean>\n openNotificationListenerSettings(): Promise<boolean>\n isBetaBuild(): Promise<boolean>\n // location services commands\n showLocationServicesDialog(): Promise<boolean>\n isLocationServicesEnabled(): Promise<boolean>\n openLocationSettings(): Promise<boolean>\n openAppSettings(): Promise<boolean>\n openBluetoothSettings(): Promise<boolean>\n\n // Image Processing Commands\n processGalleryImage(\n inputPath: string,\n outputPath: string,\n options: {\n lensCorrection?: boolean\n colorCorrection?: boolean\n },\n ): Promise<{\n success: boolean\n outputPath?: string\n processingTimeMs?: number\n error?: string\n }>\n\n mergeHdrBrackets(\n underPath: string,\n normalPath: string,\n overPath: string,\n outputPath: string,\n ): Promise<{\n success: boolean\n outputPath?: string\n processingTimeMs?: number\n error?: string\n }>\n\n stabilizeVideo(\n inputPath: string,\n imuPath: string,\n outputPath: string,\n ): Promise<{\n success: boolean\n outputPath?: string\n processingTimeMs?: number\n error?: string\n }>\n\n // Media Library Commands\n saveToGalleryWithDate(\n filePath: string,\n captureTimeMillis?: number,\n displayName?: string,\n ): Promise<{\n success: boolean\n uri?: string\n identifier?: string\n error?: string\n }>\n\n // Navigation (Android only — iOS stubs return error)\n startNavigation(\n lat: number,\n lng: number,\n options?: {\n simulate?: boolean\n speedMultiplier?: number\n /** Optional multi-stop list. When present takes precedence over lat/lng. Last entry is the final destination. */\n stops?: Array<{lat: number; lng: number}>\n /** \"walking\" | \"driving\" | \"cycling\" | \"two_wheeler\". Defaults driving. */\n mode?: string\n avoid?: {highways?: boolean; tolls?: boolean; ferries?: boolean}\n /** Force a reroute when the user is more than N meters past a pivot they didn't take. */\n missedTurnRerouteMeters?: number\n },\n ): Promise<{ok: boolean; error?: string}>\n stopNavigation(): Promise<{ok: boolean; error?: string}>\n\n /**\n * Show the Google Nav SDK Terms & Conditions dialog if not already\n * accepted. Idempotent — resolves immediately with `{accepted: true}`\n * when the user has already accepted (cached in-process / on-disk /\n * inside the SDK).\n */\n requestNavigationPermission(): Promise<{ok: boolean; accepted: boolean; error?: string}>\n\n /**\n * Dev-only: clear cached T&C acceptance (SDK flag + on-disk pref +\n * in-process flag) so the next requestNavigationPermission() re-shows\n * the dialog. Android only; iOS returns {ok: false, error: \"not\n * supported on iOS\"}.\n */\n resetNavigationPermission(): Promise<{ok: boolean; error?: string}>\n\n /**\n * Dev-only: nudge the simulated user position ~offsetMeters perpendicular\n * to the route so the Nav SDK reroutes. Default 20m. Android only.\n */\n simulateDeviation(offsetMeters?: number): Promise<{ok: boolean; error?: string}>\n\n /**\n * Dev toggle. When enabled, the native NavigationManager shifts every\n * reported location ~8m perpendicular to the route bearing, simulating\n * a pedestrian walking on the wrong sidewalk. Only meaningful in\n * simulate mode; lets us verify the SDK's along-path pivot trigger\n * fires even when the user never crosses the 7m pivot point radius.\n * Android-only today; iOS is a no-op stub.\n */\n setWrongSidewalkOffset(enabled: boolean): Promise<{ok: boolean; error?: string}>\n\n /**\n * Dev toggle. When enabled, the native NavigationManager takes over\n * from the Google simulator and walks the user along a modified\n * polyline that omits crossing micro-steps — reproducing the\n * wrong-sidewalk-then-missed-the-turn scenario. Android-only today;\n * iOS is a no-op stub.\n */\n setSkipCrossings(enabled: boolean): Promise<{ok: boolean; error?: string}>\n\n // Heading / compass (Android only)\n startHeading(): Promise<{ok: boolean; error?: string}>\n stopHeading(): Promise<{ok: boolean; error?: string}>\n\n // MentraJS Runtime — per-miniapp JSContext lifecycle.\n /**\n * Spawn a per-miniapp JS context. Re-spawn is allowed: a live context\n * with the same packageName is killed first. Returns true if the\n * polyfill bundle + miniapp source evaluated without throwing.\n *\n * The polyfill bundle is `mobile/modules/mentrajs-runtime/dist/startup.js`\n * (shipped inside the host binary). It installs window-style globals\n * (console, timers, fetch, localStorage, crypto) atop the JSC runtime.\n */\n mentraJsSpawn(packageName: string, polyfillBundle: string, miniappJs: string): Promise<boolean>\n mentraJsEvaluate(packageName: string, source: string): Promise<unknown>\n mentraJsKill(packageName: string): Promise<void>\n /**\n * Push a `{kind: \"event\"|\"response\", …}` envelope into the named\n * context's globalThis.__deliver. Returns when the underlying\n * evaluateScript completes (the JS handler runs synchronously on the\n * context's queue).\n */\n mentraJsDispatchToJs(packageName: string, envelope: Record<string, unknown>): Promise<void>\n mentraJsSetManifest(packageName: string, permissions: string[]): Promise<void>\n /** Diagnostic — returns the packageNames of every live JSContext. */\n mentraJsAlivePackages(): string[]\n /** Diagnostic — force a GC cycle on the named context. */\n mentraJsDebugForceGC(packageName: string): Promise<boolean>\n /**\n * Read the bundled MentraJS polyfill (startup.js) shipped inside the\n * host binary. Synchronous — host RN code calls this once on app boot\n * and caches the string, then passes it to every mentraJsSpawn so\n * every JSContext starts with the same polyfill ABI.\n */\n mentraJsLoadPolyfillBundle(): string\n}\n\n// This call loads the native module object from the JSI.\nexport default requireNativeModule<CrustModule>(\"Crust\")\n"]}
1
+ {"version":3,"file":"CrustModule.js","sourceRoot":"","sources":["../src/CrustModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,mBAAmB,EAAC,MAAM,MAAM,CAAA;AA6LtD,yDAAyD;AACzD,eAAe,mBAAmB,CAAc,OAAO,CAAC,CAAA","sourcesContent":["import {NativeModule, requireNativeModule} from \"expo\"\n\nimport {CrustModuleEvents, InstalledApp} from \"./Crust.types\"\n\ndeclare class CrustModule extends NativeModule<CrustModuleEvents> {\n PI: number\n hello(): string\n setValueAsync(value: string): Promise<void>\n nativeHttpRequest(\n method: string,\n url: string,\n headers: Record<string, string>,\n body?: string | null,\n ): Promise<{status: number; statusText: string; headers: Record<string, string>; body: string}>\n showAVRoutePicker(tintColor?: string | null): void\n\n /**\n * iOS: configure `preferredScreenEdgesDeferringSystemGestures`. When an\n * edge is deferred, the first swipe across that edge is consumed by the\n * app and the system gesture (Control Center, Notification Center, Home\n * indicator) only fires on a second swipe — i.e. a two-swipe-to-exit UX.\n *\n * Pass `[]` to restore default behavior. Android: no-op (Android has no\n * per-app equivalent; system gestures are configured at the OS level).\n */\n setDeferredSystemGestures(edges: Array<\"top\" | \"bottom\" | \"left\" | \"right\" | \"all\">): Promise<void>\n\n // MentraOS Notification Commands\n setNotificationConfig(enabled: boolean, blocklist: string[]): Promise<void>\n getInstalledApps(): Promise<InstalledApp[]>\n getInstalledAppsForNotifications(): Promise<InstalledApp[]>\n hasNotificationListenerPermission(): Promise<boolean>\n openNotificationListenerSettings(): Promise<boolean>\n isBetaBuild(): Promise<boolean>\n // location services commands\n showLocationServicesDialog(): Promise<boolean>\n isLocationServicesEnabled(): Promise<boolean>\n openLocationSettings(): Promise<boolean>\n openAppSettings(): Promise<boolean>\n openBluetoothSettings(): Promise<boolean>\n\n // Image Processing Commands\n processGalleryImage(\n inputPath: string,\n outputPath: string,\n options: {\n lensCorrection?: boolean\n colorCorrection?: boolean\n },\n ): Promise<{\n success: boolean\n outputPath?: string\n processingTimeMs?: number\n error?: string\n }>\n\n mergeHdrBrackets(\n underPath: string,\n normalPath: string,\n overPath: string,\n outputPath: string,\n ): Promise<{\n success: boolean\n outputPath?: string\n processingTimeMs?: number\n error?: string\n }>\n\n stabilizeVideo(\n inputPath: string,\n imuPath: string,\n outputPath: string,\n ): Promise<{\n success: boolean\n outputPath?: string\n processingTimeMs?: number\n error?: string\n }>\n\n // Media Library Commands\n saveToGalleryWithDate(\n filePath: string,\n captureTimeMillis?: number,\n displayName?: string,\n ): Promise<{\n success: boolean\n uri?: string\n identifier?: string\n existing?: boolean\n error?: string\n }>\n\n // Navigation (Android only — iOS stubs return error)\n startNavigation(\n lat: number,\n lng: number,\n options?: {\n simulate?: boolean\n speedMultiplier?: number\n /** Optional multi-stop list. When present takes precedence over lat/lng. Last entry is the final destination. */\n stops?: Array<{lat: number; lng: number}>\n /** \"walking\" | \"driving\" | \"cycling\" | \"two_wheeler\". Defaults driving. */\n mode?: string\n avoid?: {highways?: boolean; tolls?: boolean; ferries?: boolean}\n /** Force a reroute when the user is more than N meters past a pivot they didn't take. */\n missedTurnRerouteMeters?: number\n },\n ): Promise<{ok: boolean; error?: string}>\n stopNavigation(): Promise<{ok: boolean; error?: string}>\n\n /**\n * Show the Google Nav SDK Terms & Conditions dialog if not already\n * accepted. Idempotent — resolves immediately with `{accepted: true}`\n * when the user has already accepted (cached in-process / on-disk /\n * inside the SDK).\n */\n requestNavigationPermission(): Promise<{ok: boolean; accepted: boolean; error?: string}>\n\n /**\n * Dev-only: clear cached T&C acceptance (SDK flag + on-disk pref +\n * in-process flag) so the next requestNavigationPermission() re-shows\n * the dialog. Android only; iOS returns {ok: false, error: \"not\n * supported on iOS\"}.\n */\n resetNavigationPermission(): Promise<{ok: boolean; error?: string}>\n\n /**\n * Dev-only: nudge the simulated user position ~offsetMeters perpendicular\n * to the route so the Nav SDK reroutes. Default 20m. Android only.\n */\n simulateDeviation(offsetMeters?: number): Promise<{ok: boolean; error?: string}>\n\n /**\n * Dev toggle. When enabled, the native NavigationManager shifts every\n * reported location ~8m perpendicular to the route bearing, simulating\n * a pedestrian walking on the wrong sidewalk. Only meaningful in\n * simulate mode; lets us verify the SDK's along-path pivot trigger\n * fires even when the user never crosses the 7m pivot point radius.\n * Android-only today; iOS is a no-op stub.\n */\n setWrongSidewalkOffset(enabled: boolean): Promise<{ok: boolean; error?: string}>\n\n /**\n * Dev toggle. When enabled, the native NavigationManager takes over\n * from the Google simulator and walks the user along a modified\n * polyline that omits crossing micro-steps — reproducing the\n * wrong-sidewalk-then-missed-the-turn scenario. Android-only today;\n * iOS is a no-op stub.\n */\n setSkipCrossings(enabled: boolean): Promise<{ok: boolean; error?: string}>\n\n // Heading / compass (Android only)\n startHeading(): Promise<{ok: boolean; error?: string}>\n stopHeading(): Promise<{ok: boolean; error?: string}>\n\n // MentraJS Runtime — per-miniapp JSContext lifecycle.\n /**\n * Spawn a per-miniapp JS context. Re-spawn is allowed: a live context\n * with the same packageName is killed first. Returns true if the\n * polyfill bundle + miniapp source evaluated without throwing.\n *\n * The polyfill bundle is `mobile/modules/mentrajs-runtime/dist/startup.js`\n * (shipped inside the host binary). It installs window-style globals\n * (console, timers, fetch, localStorage, crypto) atop the JSC runtime.\n */\n mentraJsSpawn(packageName: string, polyfillBundle: string, miniappJs: string): Promise<boolean>\n mentraJsEvaluate(packageName: string, source: string): Promise<unknown>\n mentraJsKill(packageName: string): Promise<void>\n /**\n * Push a `{kind: \"event\"|\"response\", …}` envelope into the named\n * context's globalThis.__deliver. Returns when the underlying\n * evaluateScript completes (the JS handler runs synchronously on the\n * context's queue).\n */\n mentraJsDispatchToJs(packageName: string, envelope: Record<string, unknown>): Promise<void>\n mentraJsSetManifest(packageName: string, permissions: string[]): Promise<void>\n /** Diagnostic — returns the packageNames of every live JSContext. */\n mentraJsAlivePackages(): string[]\n /** Diagnostic — force a GC cycle on the named context. */\n mentraJsDebugForceGC(packageName: string): Promise<boolean>\n /**\n * Read the bundled MentraJS polyfill (startup.js) shipped inside the\n * host binary. Synchronous — host RN code calls this once on app boot\n * and caches the string, then passes it to every mentraJsSpawn so\n * every JSContext starts with the same polyfill ABI.\n */\n mentraJsLoadPolyfillBundle(): string\n}\n\n// This call loads the native module object from the JSI.\nexport default requireNativeModule<CrustModule>(\"Crust\")\n"]}
@@ -3,6 +3,14 @@ import { CrustModuleEvents } from "./Crust.types";
3
3
  declare class CrustModule extends NativeModule<CrustModuleEvents> {
4
4
  PI: number;
5
5
  setValueAsync(value: string): Promise<void>;
6
+ nativeHttpRequest(method: string, url: string, headers: Record<string, string>, body?: string | null): Promise<{
7
+ status: number;
8
+ statusText: string;
9
+ headers: {
10
+ [k: string]: string;
11
+ };
12
+ body: string;
13
+ }>;
6
14
  hello(): string;
7
15
  showAVRoutePicker(_tintColor?: string | null): void;
8
16
  setDeferredSystemGestures(_edges: string[]): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"CrustModule.web.d.ts","sourceRoot":"","sources":["../src/CrustModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,YAAY,EAAC,MAAM,MAAM,CAAA;AAEpD,OAAO,EAAC,iBAAiB,EAAC,MAAM,eAAe,CAAA;AAE/C,cAAM,WAAY,SAAQ,YAAY,CAAC,iBAAiB,CAAC;IACvD,EAAE,SAAU;IACN,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAGjD,KAAK;IAGL,iBAAiB,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI;IACtC,yBAAyB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAC1D,qBAAqB,CAAC,QAAQ,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAC7E,gBAAgB;IAGhB,gCAAgC;IAGhC,iCAAiC;IAGjC,gCAAgC;IAGhC,WAAW;IAGX,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM;IAGjE,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;IAG3C,YAAY,CAAC,IAAI,EAAE,MAAM;IAGzB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAGhE,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IAGxD,qBAAqB;IAGf,oBAAoB,CAAC,IAAI,EAAE,MAAM;IAGvC,0BAA0B;CAG3B;;AAED,wBAA4D"}
1
+ {"version":3,"file":"CrustModule.web.d.ts","sourceRoot":"","sources":["../src/CrustModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,YAAY,EAAC,MAAM,MAAM,CAAA;AAEpD,OAAO,EAAC,iBAAiB,EAAC,MAAM,eAAe,CAAA;AAE/C,cAAM,WAAY,SAAQ,YAAY,CAAC,iBAAiB,CAAC;IACvD,EAAE,SAAU;IACN,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAG3C,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI;;;;;;;;IAS1G,KAAK;IAGL,iBAAiB,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI;IACtC,yBAAyB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAC1D,qBAAqB,CAAC,QAAQ,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAC7E,gBAAgB;IAGhB,gCAAgC;IAGhC,iCAAiC;IAGjC,gCAAgC;IAGhC,WAAW;IAGX,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM;IAGjE,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;IAG3C,YAAY,CAAC,IAAI,EAAE,MAAM;IAGzB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAGhE,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;IAGxD,qBAAqB;IAGf,oBAAoB,CAAC,IAAI,EAAE,MAAM;IAGvC,0BAA0B;CAG3B;;AAED,wBAA4D"}
@@ -4,6 +4,15 @@ class CrustModule extends NativeModule {
4
4
  async setValueAsync(value) {
5
5
  this.emit("onChange", { value });
6
6
  }
7
+ async nativeHttpRequest(method, url, headers, body) {
8
+ const response = await fetch(url, { method, headers, body });
9
+ return {
10
+ status: response.status,
11
+ statusText: response.statusText,
12
+ headers: Object.fromEntries(response.headers.entries()),
13
+ body: await response.text(),
14
+ };
15
+ }
7
16
  hello() {
8
17
  return "Hello world! 👋";
9
18
  }
@@ -1 +1 @@
1
- {"version":3,"file":"CrustModule.web.js","sourceRoot":"","sources":["../src/CrustModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,iBAAiB,EAAE,YAAY,EAAC,MAAM,MAAM,CAAA;AAIpD,MAAM,WAAY,SAAQ,YAA+B;IACvD,EAAE,GAAG,IAAI,CAAC,EAAE,CAAA;IACZ,KAAK,CAAC,aAAa,CAAC,KAAa;QAC/B,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,EAAC,KAAK,EAAC,CAAC,CAAA;IAChC,CAAC;IACD,KAAK;QACH,OAAO,iBAAiB,CAAA;IAC1B,CAAC;IACD,iBAAiB,CAAC,UAA0B,IAAG,CAAC;IAChD,KAAK,CAAC,yBAAyB,CAAC,MAAgB,IAAkB,CAAC;IACnE,KAAK,CAAC,qBAAqB,CAAC,QAAiB,EAAE,UAAoB,IAAkB,CAAC;IACtF,KAAK,CAAC,gBAAgB;QACpB,OAAO,EAAE,CAAA;IACX,CAAC;IACD,KAAK,CAAC,gCAAgC;QACpC,OAAO,EAAE,CAAA;IACX,CAAC;IACD,KAAK,CAAC,iCAAiC;QACrC,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,gCAAgC;QACpC,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,WAAW;QACf,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,aAAa,CAAC,IAAY,EAAE,SAAiB,EAAE,UAAkB;QACrE,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,gBAAgB,CAAC,IAAY,EAAE,IAAY;QAC/C,OAAO,IAAI,CAAA;IACb,CAAC;IACD,KAAK,CAAC,YAAY,CAAC,IAAY;QAC7B,OAAM;IACR,CAAC;IACD,KAAK,CAAC,oBAAoB,CAAC,IAAY,EAAE,IAA6B;QACpE,OAAM;IACR,CAAC;IACD,KAAK,CAAC,mBAAmB,CAAC,IAAY,EAAE,MAAgB;QACtD,OAAM;IACR,CAAC;IACD,qBAAqB;QACnB,OAAO,EAAE,CAAA;IACX,CAAC;IACD,KAAK,CAAC,oBAAoB,CAAC,IAAY;QACrC,OAAO,KAAK,CAAA;IACd,CAAC;IACD,0BAA0B;QACxB,OAAO,EAAE,CAAA;IACX,CAAC;CACF;AAED,eAAe,iBAAiB,CAAC,WAAW,EAAE,aAAa,CAAC,CAAA","sourcesContent":["import {registerWebModule, NativeModule} from \"expo\"\n\nimport {CrustModuleEvents} from \"./Crust.types\"\n\nclass CrustModule extends NativeModule<CrustModuleEvents> {\n PI = Math.PI\n async setValueAsync(value: string): Promise<void> {\n this.emit(\"onChange\", {value})\n }\n hello() {\n return \"Hello world! 👋\"\n }\n showAVRoutePicker(_tintColor?: string | null) {}\n async setDeferredSystemGestures(_edges: string[]): Promise<void> {}\n async setNotificationConfig(_enabled: boolean, _blocklist: string[]): Promise<void> {}\n async getInstalledApps() {\n return []\n }\n async getInstalledAppsForNotifications() {\n return []\n }\n async hasNotificationListenerPermission() {\n return false\n }\n async openNotificationListenerSettings() {\n return false\n }\n async isBetaBuild() {\n return false\n }\n async mentraJsSpawn(_pkg: string, _polyfill: string, _miniappJs: string) {\n return false\n }\n async mentraJsEvaluate(_pkg: string, _src: string) {\n return null\n }\n async mentraJsKill(_pkg: string) {\n return\n }\n async mentraJsDispatchToJs(_pkg: string, _env: Record<string, unknown>) {\n return\n }\n async mentraJsSetManifest(_pkg: string, _perms: string[]) {\n return\n }\n mentraJsAlivePackages() {\n return []\n }\n async mentraJsDebugForceGC(_pkg: string) {\n return false\n }\n mentraJsLoadPolyfillBundle() {\n return \"\"\n }\n}\n\nexport default registerWebModule(CrustModule, \"CrustModule\")\n"]}
1
+ {"version":3,"file":"CrustModule.web.js","sourceRoot":"","sources":["../src/CrustModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,iBAAiB,EAAE,YAAY,EAAC,MAAM,MAAM,CAAA;AAIpD,MAAM,WAAY,SAAQ,YAA+B;IACvD,EAAE,GAAG,IAAI,CAAC,EAAE,CAAA;IACZ,KAAK,CAAC,aAAa,CAAC,KAAa;QAC/B,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,EAAC,KAAK,EAAC,CAAC,CAAA;IAChC,CAAC;IACD,KAAK,CAAC,iBAAiB,CAAC,MAAc,EAAE,GAAW,EAAE,OAA+B,EAAE,IAAoB;QACxG,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE,EAAC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAC,CAAC,CAAA;QAC1D,OAAO;YACL,MAAM,EAAE,QAAQ,CAAC,MAAM;YACvB,UAAU,EAAE,QAAQ,CAAC,UAAU;YAC/B,OAAO,EAAE,MAAM,CAAC,WAAW,CAAC,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;YACvD,IAAI,EAAE,MAAM,QAAQ,CAAC,IAAI,EAAE;SAC5B,CAAA;IACH,CAAC;IACD,KAAK;QACH,OAAO,iBAAiB,CAAA;IAC1B,CAAC;IACD,iBAAiB,CAAC,UAA0B,IAAG,CAAC;IAChD,KAAK,CAAC,yBAAyB,CAAC,MAAgB,IAAkB,CAAC;IACnE,KAAK,CAAC,qBAAqB,CAAC,QAAiB,EAAE,UAAoB,IAAkB,CAAC;IACtF,KAAK,CAAC,gBAAgB;QACpB,OAAO,EAAE,CAAA;IACX,CAAC;IACD,KAAK,CAAC,gCAAgC;QACpC,OAAO,EAAE,CAAA;IACX,CAAC;IACD,KAAK,CAAC,iCAAiC;QACrC,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,gCAAgC;QACpC,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,WAAW;QACf,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,aAAa,CAAC,IAAY,EAAE,SAAiB,EAAE,UAAkB;QACrE,OAAO,KAAK,CAAA;IACd,CAAC;IACD,KAAK,CAAC,gBAAgB,CAAC,IAAY,EAAE,IAAY;QAC/C,OAAO,IAAI,CAAA;IACb,CAAC;IACD,KAAK,CAAC,YAAY,CAAC,IAAY;QAC7B,OAAM;IACR,CAAC;IACD,KAAK,CAAC,oBAAoB,CAAC,IAAY,EAAE,IAA6B;QACpE,OAAM;IACR,CAAC;IACD,KAAK,CAAC,mBAAmB,CAAC,IAAY,EAAE,MAAgB;QACtD,OAAM;IACR,CAAC;IACD,qBAAqB;QACnB,OAAO,EAAE,CAAA;IACX,CAAC;IACD,KAAK,CAAC,oBAAoB,CAAC,IAAY;QACrC,OAAO,KAAK,CAAA;IACd,CAAC;IACD,0BAA0B;QACxB,OAAO,EAAE,CAAA;IACX,CAAC;CACF;AAED,eAAe,iBAAiB,CAAC,WAAW,EAAE,aAAa,CAAC,CAAA","sourcesContent":["import {registerWebModule, NativeModule} from \"expo\"\n\nimport {CrustModuleEvents} from \"./Crust.types\"\n\nclass CrustModule extends NativeModule<CrustModuleEvents> {\n PI = Math.PI\n async setValueAsync(value: string): Promise<void> {\n this.emit(\"onChange\", {value})\n }\n async nativeHttpRequest(method: string, url: string, headers: Record<string, string>, body?: string | null) {\n const response = await fetch(url, {method, headers, body})\n return {\n status: response.status,\n statusText: response.statusText,\n headers: Object.fromEntries(response.headers.entries()),\n body: await response.text(),\n }\n }\n hello() {\n return \"Hello world! 👋\"\n }\n showAVRoutePicker(_tintColor?: string | null) {}\n async setDeferredSystemGestures(_edges: string[]): Promise<void> {}\n async setNotificationConfig(_enabled: boolean, _blocklist: string[]): Promise<void> {}\n async getInstalledApps() {\n return []\n }\n async getInstalledAppsForNotifications() {\n return []\n }\n async hasNotificationListenerPermission() {\n return false\n }\n async openNotificationListenerSettings() {\n return false\n }\n async isBetaBuild() {\n return false\n }\n async mentraJsSpawn(_pkg: string, _polyfill: string, _miniappJs: string) {\n return false\n }\n async mentraJsEvaluate(_pkg: string, _src: string) {\n return null\n }\n async mentraJsKill(_pkg: string) {\n return\n }\n async mentraJsDispatchToJs(_pkg: string, _env: Record<string, unknown>) {\n return\n }\n async mentraJsSetManifest(_pkg: string, _perms: string[]) {\n return\n }\n mentraJsAlivePackages() {\n return []\n }\n async mentraJsDebugForceGC(_pkg: string) {\n return false\n }\n mentraJsLoadPolyfillBundle() {\n return \"\"\n }\n}\n\nexport default registerWebModule(CrustModule, \"CrustModule\")\n"]}
@@ -8,6 +8,59 @@ private enum MentraSyncedMediaAlbum {
8
8
  static let localizedTitle = "Mentra"
9
9
  }
10
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
+
11
64
  public class CrustModule: Module {
12
65
  public func definition() -> ModuleDefinition {
13
66
  Name("Crust")
@@ -411,66 +464,135 @@ public class CrustModule: Module {
411
464
  // MARK: - Media Library Commands
412
465
 
413
466
  AsyncFunction("saveToGalleryWithDate") {
414
- (filePath: String, captureTimeMillis: Int64?, displayName: String?) -> [String: Any] in
467
+ (
468
+ filePath: String,
469
+ captureTimeMillis: Int64?,
470
+ displayName: String?
471
+ ) -> [String: Any] in
415
472
  let fileURL = URL(fileURLWithPath: filePath)
416
473
 
417
474
  guard FileManager.default.fileExists(atPath: filePath) else {
418
475
  return ["success": false, "error": "File does not exist"]
419
476
  }
420
477
 
421
- var assetIdentifier: String?
422
- let semaphore = DispatchSemaphore(value: 0)
423
- var resultError: Error?
424
- var creationFailed = false
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
+ }
425
505
 
426
- PHPhotoLibrary.shared().performChanges {
427
- let pathExtension = fileURL.pathExtension.lowercased()
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
+ }
428
523
 
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
- }
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
+ }
451
547
 
452
- if let captureMillis = captureTimeMillis {
453
- let captureDate = Date(
454
- timeIntervalSince1970: TimeInterval(captureMillis) / 1000.0
455
- )
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 {
456
575
  creationRequest.creationDate = captureDate
457
576
  NSLog("CrustModule: Setting creation date to: \(captureDate)")
458
577
  }
459
578
 
460
579
  guard let assetPlaceholder = creationRequest.placeholderForCreatedAsset else {
461
580
  NSLog("CrustModule: Missing placeholder for created asset")
462
- creationFailed = true
581
+ state.markCreationFailed()
463
582
  return
464
583
  }
584
+ state.setAssetIdentifier(assetPlaceholder.localIdentifier)
465
585
 
466
- assetIdentifier = assetPlaceholder.localIdentifier
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 }
467
590
 
468
591
  let albumFetch = PHFetchOptions()
469
592
  albumFetch.predicate = NSPredicate(
470
593
  format: "localizedTitle == %@", MentraSyncedMediaAlbum.localizedTitle
471
594
  )
472
595
  albumFetch.fetchLimit = 1
473
-
474
596
  let existingAlbums = PHAssetCollection.fetchAssetCollections(
475
597
  with: .album,
476
598
  subtype: .albumRegular,
@@ -491,26 +613,141 @@ public class CrustModule: Module {
491
613
  "CrustModule: Mentra album exists but is not writable; asset saved to library only"
492
614
  )
493
615
  }
494
- } completionHandler: { _, error in
495
- resultError = error
496
- semaphore.signal()
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
+ ))
497
625
  }
498
626
 
499
- semaphore.wait()
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
+ }
500
639
 
501
- if creationFailed {
502
- return ["success": false, "error": "Failed to create asset request - file may be corrupted or unsupported"]
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
503
691
  }
692
+ }
504
693
 
505
- if let error = resultError {
506
- NSLog("CrustModule: Error saving to gallery: \(error.localizedDescription)")
507
- return ["success": false, "error": error.localizedDescription]
694
+ var legacyIdentifiers: [String] = []
695
+ for asset in legacyCandidates {
696
+ if await self.assetOriginalFile(asset, matchesByteCount: expectedFileSize) {
697
+ legacyIdentifiers.append(asset.localIdentifier)
508
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
+ }
509
704
 
510
- NSLog("CrustModule: Successfully saved to gallery with proper creation date")
511
- return ["success": true, "identifier": assetIdentifier ?? ""]
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
+ }
512
734
  }
513
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
+ }
514
751
  }
515
752
  }
516
753
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/crust",
3
- "version": "0.1.0-dev.0",
3
+ "version": "0.1.0-dev.1",
4
4
  "description": "Mentra Native Module",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -6,6 +6,12 @@ declare class CrustModule extends NativeModule<CrustModuleEvents> {
6
6
  PI: number
7
7
  hello(): string
8
8
  setValueAsync(value: string): Promise<void>
9
+ nativeHttpRequest(
10
+ method: string,
11
+ url: string,
12
+ headers: Record<string, string>,
13
+ body?: string | null,
14
+ ): Promise<{status: number; statusText: string; headers: Record<string, string>; body: string}>
9
15
  showAVRoutePicker(tintColor?: string | null): void
10
16
 
11
17
  /**
@@ -17,9 +23,7 @@ declare class CrustModule extends NativeModule<CrustModuleEvents> {
17
23
  * Pass `[]` to restore default behavior. Android: no-op (Android has no
18
24
  * per-app equivalent; system gestures are configured at the OS level).
19
25
  */
20
- setDeferredSystemGestures(
21
- edges: Array<"top" | "bottom" | "left" | "right" | "all">,
22
- ): Promise<void>
26
+ setDeferredSystemGestures(edges: Array<"top" | "bottom" | "left" | "right" | "all">): Promise<void>
23
27
 
24
28
  // MentraOS Notification Commands
25
29
  setNotificationConfig(enabled: boolean, blocklist: string[]): Promise<void>
@@ -82,6 +86,7 @@ declare class CrustModule extends NativeModule<CrustModuleEvents> {
82
86
  success: boolean
83
87
  uri?: string
84
88
  identifier?: string
89
+ existing?: boolean
85
90
  error?: string
86
91
  }>
87
92
 
@@ -7,6 +7,15 @@ class CrustModule extends NativeModule<CrustModuleEvents> {
7
7
  async setValueAsync(value: string): Promise<void> {
8
8
  this.emit("onChange", {value})
9
9
  }
10
+ async nativeHttpRequest(method: string, url: string, headers: Record<string, string>, body?: string | null) {
11
+ const response = await fetch(url, {method, headers, body})
12
+ return {
13
+ status: response.status,
14
+ statusText: response.statusText,
15
+ headers: Object.fromEntries(response.headers.entries()),
16
+ body: await response.text(),
17
+ }
18
+ }
10
19
  hello() {
11
20
  return "Hello world! 👋"
12
21
  }