@mentra/engine 0.1.0-beta.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.
Files changed (355) hide show
  1. package/README.md +42 -1
  2. package/build/engine.d.ts +11 -3
  3. package/build/engine.d.ts.map +1 -1
  4. package/build/engine.js +7 -2
  5. package/build/engine.js.map +1 -1
  6. package/build/facades/dev.d.ts.map +1 -1
  7. package/build/facades/dev.js +9 -5
  8. package/build/facades/dev.js.map +1 -1
  9. package/build/facades/gallery.js +1 -1
  10. package/build/facades/gallery.js.map +1 -1
  11. package/build/facades/glasses.d.ts +1 -0
  12. package/build/facades/glasses.d.ts.map +1 -1
  13. package/build/facades/glasses.js +19 -4
  14. package/build/facades/glasses.js.map +1 -1
  15. package/build/facades/glassesSettings.d.ts.map +1 -1
  16. package/build/facades/glassesSettings.js +1 -0
  17. package/build/facades/glassesSettings.js.map +1 -1
  18. package/build/facades/miniapps.d.ts +4 -0
  19. package/build/facades/miniapps.d.ts.map +1 -1
  20. package/build/facades/miniapps.js +5 -0
  21. package/build/facades/miniapps.js.map +1 -1
  22. package/build/facades/ota.d.ts +11 -2
  23. package/build/facades/ota.d.ts.map +1 -1
  24. package/build/facades/ota.js +19 -4
  25. package/build/facades/ota.js.map +1 -1
  26. package/build/facades/pairing.d.ts +4 -0
  27. package/build/facades/pairing.d.ts.map +1 -1
  28. package/build/facades/pairing.js +10 -8
  29. package/build/facades/pairing.js.map +1 -1
  30. package/build/facades/phoneNotifications.d.ts +0 -4
  31. package/build/facades/phoneNotifications.d.ts.map +1 -1
  32. package/build/facades/phoneNotifications.js +4 -14
  33. package/build/facades/phoneNotifications.js.map +1 -1
  34. package/build/index.d.ts +6 -3
  35. package/build/index.d.ts.map +1 -1
  36. package/build/index.js +4 -2
  37. package/build/index.js.map +1 -1
  38. package/build/internal.d.ts +2 -2
  39. package/build/internal.d.ts.map +1 -1
  40. package/build/internal.js +1 -1
  41. package/build/internal.js.map +1 -1
  42. package/build/react/MentraLiveOtaFlow.d.ts +38 -0
  43. package/build/react/MentraLiveOtaFlow.d.ts.map +1 -0
  44. package/build/react/MentraLiveOtaFlow.js +528 -0
  45. package/build/react/MentraLiveOtaFlow.js.map +1 -0
  46. package/build/react/index.d.ts +2 -0
  47. package/build/react/index.d.ts.map +1 -0
  48. package/build/react/index.js +2 -0
  49. package/build/react/index.js.map +1 -0
  50. package/build/react/useEngineSnapshot.d.ts +2 -0
  51. package/build/react/useEngineSnapshot.d.ts.map +1 -0
  52. package/build/react/useEngineSnapshot.js +14 -0
  53. package/build/react/useEngineSnapshot.js.map +1 -0
  54. package/build/runtime/bootstrap.d.ts +24 -1
  55. package/build/runtime/bootstrap.d.ts.map +1 -1
  56. package/build/runtime/bootstrap.js +17 -0
  57. package/build/runtime/bootstrap.js.map +1 -1
  58. package/build/runtime/scanQrSeam.d.ts +7 -0
  59. package/build/runtime/scanQrSeam.d.ts.map +1 -0
  60. package/build/runtime/scanQrSeam.js +16 -0
  61. package/build/runtime/scanQrSeam.js.map +1 -0
  62. package/build/services/AppRegistry.d.ts +18 -8
  63. package/build/services/AppRegistry.d.ts.map +1 -1
  64. package/build/services/AppRegistry.js +44 -45
  65. package/build/services/AppRegistry.js.map +1 -1
  66. package/build/services/AudioCloudUplink.d.ts +9 -3
  67. package/build/services/AudioCloudUplink.d.ts.map +1 -1
  68. package/build/services/AudioCloudUplink.js +9 -3
  69. package/build/services/AudioCloudUplink.js.map +1 -1
  70. package/build/services/AudioPlaybackService.d.ts +9 -1
  71. package/build/services/AudioPlaybackService.d.ts.map +1 -1
  72. package/build/services/AudioPlaybackService.js +77 -32
  73. package/build/services/AudioPlaybackService.js.map +1 -1
  74. package/build/services/BlobStore.d.ts +2 -1
  75. package/build/services/BlobStore.d.ts.map +1 -1
  76. package/build/services/BlobStore.js +17 -9
  77. package/build/services/BlobStore.js.map +1 -1
  78. package/build/services/CloudAudioSubscriptionSync.d.ts.map +1 -1
  79. package/build/services/CloudAudioSubscriptionSync.js +13 -1
  80. package/build/services/CloudAudioSubscriptionSync.js.map +1 -1
  81. package/build/services/CloudClientService.d.ts +15 -0
  82. package/build/services/CloudClientService.d.ts.map +1 -1
  83. package/build/services/CloudClientService.js +75 -0
  84. package/build/services/CloudClientService.js.map +1 -1
  85. package/build/services/ForegroundLocationPermission.d.ts +13 -0
  86. package/build/services/ForegroundLocationPermission.d.ts.map +1 -0
  87. package/build/services/ForegroundLocationPermission.js +12 -0
  88. package/build/services/ForegroundLocationPermission.js.map +1 -0
  89. package/build/services/GlassesSettingsSync.d.ts.map +1 -1
  90. package/build/services/GlassesSettingsSync.js +9 -3
  91. package/build/services/GlassesSettingsSync.js.map +1 -1
  92. package/build/services/GlassesStatusProjection.d.ts +2 -1
  93. package/build/services/GlassesStatusProjection.d.ts.map +1 -1
  94. package/build/services/GlassesStatusProjection.js +15 -8
  95. package/build/services/GlassesStatusProjection.js.map +1 -1
  96. package/build/services/HotspotOtaTransport.d.ts +29 -0
  97. package/build/services/HotspotOtaTransport.d.ts.map +1 -0
  98. package/build/services/HotspotOtaTransport.js +146 -0
  99. package/build/services/HotspotOtaTransport.js.map +1 -0
  100. package/build/services/HotspotShutdown.d.ts +17 -0
  101. package/build/services/HotspotShutdown.d.ts.map +1 -0
  102. package/build/services/HotspotShutdown.js +29 -0
  103. package/build/services/HotspotShutdown.js.map +1 -0
  104. package/build/services/LocalDisplayManager.d.ts +15 -0
  105. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  106. package/build/services/LocalDisplayManager.js +106 -20
  107. package/build/services/LocalDisplayManager.js.map +1 -1
  108. package/build/services/LocalMiniappRuntime.d.ts +67 -1
  109. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  110. package/build/services/LocalMiniappRuntime.js +486 -126
  111. package/build/services/LocalMiniappRuntime.js.map +1 -1
  112. package/build/services/LocalMiniappStorage.d.ts +32 -0
  113. package/build/services/LocalMiniappStorage.d.ts.map +1 -0
  114. package/build/services/LocalMiniappStorage.js +66 -0
  115. package/build/services/LocalMiniappStorage.js.map +1 -0
  116. package/build/services/LocalMiniappUserIdentity.d.ts +20 -0
  117. package/build/services/LocalMiniappUserIdentity.d.ts.map +1 -0
  118. package/build/services/LocalMiniappUserIdentity.js +43 -0
  119. package/build/services/LocalMiniappUserIdentity.js.map +1 -0
  120. package/build/services/MicStateCoordinator.d.ts +42 -1
  121. package/build/services/MicStateCoordinator.d.ts.map +1 -1
  122. package/build/services/MicStateCoordinator.js +141 -3
  123. package/build/services/MicStateCoordinator.js.map +1 -1
  124. package/build/services/MiniappLauncher.d.ts.map +1 -1
  125. package/build/services/MiniappLauncher.js +23 -3
  126. package/build/services/MiniappLauncher.js.map +1 -1
  127. package/build/services/MiniappLiveness.d.ts +12 -0
  128. package/build/services/MiniappLiveness.d.ts.map +1 -0
  129. package/build/services/MiniappLiveness.js +16 -0
  130. package/build/services/MiniappLiveness.js.map +1 -0
  131. package/build/services/OtaArtifactDownloader.d.ts +65 -0
  132. package/build/services/OtaArtifactDownloader.d.ts.map +1 -0
  133. package/build/services/OtaArtifactDownloader.js +226 -0
  134. package/build/services/OtaArtifactDownloader.js.map +1 -0
  135. package/build/services/OtaAutoChain.d.ts +34 -0
  136. package/build/services/OtaAutoChain.d.ts.map +1 -0
  137. package/build/services/OtaAutoChain.js +83 -0
  138. package/build/services/OtaAutoChain.js.map +1 -0
  139. package/build/services/OtaErrorMapping.d.ts +18 -0
  140. package/build/services/OtaErrorMapping.d.ts.map +1 -0
  141. package/build/services/OtaErrorMapping.js +63 -0
  142. package/build/services/OtaErrorMapping.js.map +1 -0
  143. package/build/services/OtaInstallCoordinator.d.ts +94 -14
  144. package/build/services/OtaInstallCoordinator.d.ts.map +1 -1
  145. package/build/services/OtaInstallCoordinator.js +574 -130
  146. package/build/services/OtaInstallCoordinator.js.map +1 -1
  147. package/build/services/OtaService.d.ts.map +1 -1
  148. package/build/services/OtaService.js +8 -1
  149. package/build/services/OtaService.js.map +1 -1
  150. package/build/services/OtaUpdateCheckService.d.ts +38 -3
  151. package/build/services/OtaUpdateCheckService.d.ts.map +1 -1
  152. package/build/services/OtaUpdateCheckService.js +142 -22
  153. package/build/services/OtaUpdateCheckService.js.map +1 -1
  154. package/build/services/PhoneCalendarService.d.ts.map +1 -1
  155. package/build/services/PhoneCalendarService.js +22 -1
  156. package/build/services/PhoneCalendarService.js.map +1 -1
  157. package/build/services/PhoneCameraFovCoordinator.d.ts +2 -0
  158. package/build/services/PhoneCameraFovCoordinator.d.ts.map +1 -1
  159. package/build/services/PhoneCameraFovCoordinator.js +14 -0
  160. package/build/services/PhoneCameraFovCoordinator.js.map +1 -1
  161. package/build/services/PhoneNotificationsSync.d.ts.map +1 -1
  162. package/build/services/PhoneNotificationsSync.js +12 -10
  163. package/build/services/PhoneNotificationsSync.js.map +1 -1
  164. package/build/services/PhonePhotoCoordinator.d.ts +10 -3
  165. package/build/services/PhonePhotoCoordinator.d.ts.map +1 -1
  166. package/build/services/PhonePhotoCoordinator.js +21 -3
  167. package/build/services/PhonePhotoCoordinator.js.map +1 -1
  168. package/build/services/PhoneStreamCoordinator.d.ts +8 -1
  169. package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
  170. package/build/services/PhoneStreamCoordinator.js +125 -13
  171. package/build/services/PhoneStreamCoordinator.js.map +1 -1
  172. package/build/services/PhoneVideoCoordinator.d.ts +2 -0
  173. package/build/services/PhoneVideoCoordinator.d.ts.map +1 -1
  174. package/build/services/PhoneVideoCoordinator.js +10 -0
  175. package/build/services/PhoneVideoCoordinator.js.map +1 -1
  176. package/build/services/SceneRenderer.d.ts.map +1 -1
  177. package/build/services/SceneRenderer.js +15 -0
  178. package/build/services/SceneRenderer.js.map +1 -1
  179. package/build/services/SupportProfileSync.d.ts +22 -0
  180. package/build/services/SupportProfileSync.d.ts.map +1 -0
  181. package/build/services/SupportProfileSync.js +213 -0
  182. package/build/services/SupportProfileSync.js.map +1 -0
  183. package/build/services/TtsTextSanitizer.d.ts +5 -0
  184. package/build/services/TtsTextSanitizer.d.ts.map +1 -0
  185. package/build/services/TtsTextSanitizer.js +61 -0
  186. package/build/services/TtsTextSanitizer.js.map +1 -0
  187. package/build/services/WifiAdbRequest.d.ts +17 -0
  188. package/build/services/WifiAdbRequest.d.ts.map +1 -0
  189. package/build/services/WifiAdbRequest.js +35 -0
  190. package/build/services/WifiAdbRequest.js.map +1 -0
  191. package/build/services/asg/asgCameraApi.d.ts +3 -0
  192. package/build/services/asg/asgCameraApi.d.ts.map +1 -1
  193. package/build/services/asg/asgCameraApi.js +72 -2
  194. package/build/services/asg/asgCameraApi.js.map +1 -1
  195. package/build/services/asg/gallerySyncService.d.ts.map +1 -1
  196. package/build/services/asg/gallerySyncService.js +26 -14
  197. package/build/services/asg/gallerySyncService.js.map +1 -1
  198. package/build/services/asg/localNetworkTransport.d.ts +2 -2
  199. package/build/services/asg/localNetworkTransport.d.ts.map +1 -1
  200. package/build/services/asg/localNetworkTransport.js +6 -6
  201. package/build/services/asg/localNetworkTransport.js.map +1 -1
  202. package/build/services/audioPlaybackAssets.d.ts +8 -0
  203. package/build/services/audioPlaybackAssets.d.ts.map +1 -0
  204. package/build/services/audioPlaybackAssets.js +7 -0
  205. package/build/services/audioPlaybackAssets.js.map +1 -0
  206. package/build/services/glassesClockSync.js +2 -2
  207. package/build/services/glassesClockSync.js.map +1 -1
  208. package/build/services/manifestActions.d.ts +7 -0
  209. package/build/services/manifestActions.d.ts.map +1 -0
  210. package/build/services/manifestActions.js +25 -0
  211. package/build/services/manifestActions.js.map +1 -0
  212. package/build/services/otaDisplayState.d.ts +22 -11
  213. package/build/services/otaDisplayState.d.ts.map +1 -1
  214. package/build/services/otaDisplayState.js +36 -14
  215. package/build/services/otaDisplayState.js.map +1 -1
  216. package/build/services/otaInstallPolicy.d.ts +15 -3
  217. package/build/services/otaInstallPolicy.d.ts.map +1 -1
  218. package/build/services/otaInstallPolicy.js +24 -3
  219. package/build/services/otaInstallPolicy.js.map +1 -1
  220. package/build/services/otaManifestUrl.d.ts +2 -0
  221. package/build/services/otaManifestUrl.d.ts.map +1 -0
  222. package/build/services/{asgOtaVersionUrl.js → otaManifestUrl.js} +15 -5
  223. package/build/services/otaManifestUrl.js.map +1 -0
  224. package/build/services/slimStreamStatus.d.ts +11 -0
  225. package/build/services/slimStreamStatus.d.ts.map +1 -1
  226. package/build/services/slimStreamStatus.js +14 -4
  227. package/build/services/slimStreamStatus.js.map +1 -1
  228. package/build/stores/apps.d.ts.map +1 -1
  229. package/build/stores/apps.js +11 -0
  230. package/build/stores/apps.js.map +1 -1
  231. package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
  232. package/build/stores/bluetoothSettingKeys.js +2 -0
  233. package/build/stores/bluetoothSettingKeys.js.map +1 -1
  234. package/build/stores/gallerySync.d.ts +2 -0
  235. package/build/stores/gallerySync.d.ts.map +1 -1
  236. package/build/stores/gallerySync.js +2 -0
  237. package/build/stores/gallerySync.js.map +1 -1
  238. package/build/stores/glasses.d.ts.map +1 -1
  239. package/build/stores/glasses.js +3 -0
  240. package/build/stores/glasses.js.map +1 -1
  241. package/build/stores/settings.d.ts +17 -0
  242. package/build/stores/settings.d.ts.map +1 -1
  243. package/build/stores/settings.js +152 -19
  244. package/build/stores/settings.js.map +1 -1
  245. package/build/types/applet.d.ts +2 -0
  246. package/build/types/applet.d.ts.map +1 -1
  247. package/build/types/applet.js.map +1 -1
  248. package/build/types/enums.d.ts +1 -0
  249. package/build/types/enums.d.ts.map +1 -1
  250. package/build/types/enums.js +1 -0
  251. package/build/types/enums.js.map +1 -1
  252. package/build/types/hardware.d.ts +1 -0
  253. package/build/types/hardware.d.ts.map +1 -1
  254. package/build/types/hardware.js +14 -0
  255. package/build/types/hardware.js.map +1 -1
  256. package/build/utils/configuredDevHost.d.ts +6 -0
  257. package/build/utils/configuredDevHost.d.ts.map +1 -0
  258. package/build/utils/configuredDevHost.js +19 -0
  259. package/build/utils/configuredDevHost.js.map +1 -0
  260. package/build/utils/devMiniappLaunch.d.ts +19 -8
  261. package/build/utils/devMiniappLaunch.d.ts.map +1 -1
  262. package/build/utils/devMiniappLaunch.js +144 -21
  263. package/build/utils/devMiniappLaunch.js.map +1 -1
  264. package/build/utils/diagnosticContext.d.ts.map +1 -1
  265. package/build/utils/diagnosticContext.js +15 -17
  266. package/build/utils/diagnosticContext.js.map +1 -1
  267. package/build/utils/hardware/hardware.d.ts.map +1 -1
  268. package/build/utils/hardware/hardware.js +7 -3
  269. package/build/utils/hardware/hardware.js.map +1 -1
  270. package/build/utils/miniappDiagnostics.d.ts +64 -0
  271. package/build/utils/miniappDiagnostics.d.ts.map +1 -0
  272. package/build/utils/miniappDiagnostics.js +173 -0
  273. package/build/utils/miniappDiagnostics.js.map +1 -0
  274. package/build/utils/timers/timers.d.ts.map +1 -1
  275. package/build/utils/timers/timers.js +55 -18
  276. package/build/utils/timers/timers.js.map +1 -1
  277. package/package.json +27 -18
  278. package/src/assets/silence.wav +0 -0
  279. package/src/engine.ts +7 -2
  280. package/src/facades/dev.ts +9 -5
  281. package/src/facades/gallery.ts +1 -1
  282. package/src/facades/glasses.ts +17 -4
  283. package/src/facades/glassesSettings.ts +5 -0
  284. package/src/facades/miniapps.ts +7 -0
  285. package/src/facades/ota.ts +19 -4
  286. package/src/facades/pairing.ts +11 -7
  287. package/src/facades/phoneNotifications.ts +4 -13
  288. package/src/index.ts +26 -2
  289. package/src/internal.ts +2 -1
  290. package/src/react/MentraLiveOtaFlow.tsx +856 -0
  291. package/src/react/index.ts +7 -0
  292. package/src/react/useEngineSnapshot.ts +16 -0
  293. package/src/runtime/bootstrap.ts +33 -1
  294. package/src/runtime/scanQrSeam.ts +20 -0
  295. package/src/services/AppRegistry.ts +72 -48
  296. package/src/services/AudioCloudUplink.ts +9 -3
  297. package/src/services/AudioPlaybackService.ts +94 -34
  298. package/src/services/BlobStore.ts +22 -9
  299. package/src/services/CloudAudioSubscriptionSync.ts +13 -1
  300. package/src/services/CloudClientService.ts +74 -0
  301. package/src/services/ForegroundLocationPermission.ts +24 -0
  302. package/src/services/GlassesSettingsSync.ts +9 -3
  303. package/src/services/GlassesStatusProjection.ts +16 -8
  304. package/src/services/HotspotOtaTransport.ts +184 -0
  305. package/src/services/HotspotShutdown.ts +41 -0
  306. package/src/services/LocalDisplayManager.ts +112 -21
  307. package/src/services/LocalMiniappRuntime.ts +576 -130
  308. package/src/services/LocalMiniappStorage.ts +82 -0
  309. package/src/services/LocalMiniappUserIdentity.ts +46 -0
  310. package/src/services/MicStateCoordinator.ts +169 -4
  311. package/src/services/MiniappLauncher.ts +30 -3
  312. package/src/services/MiniappLiveness.ts +24 -0
  313. package/src/services/OtaArtifactDownloader.ts +312 -0
  314. package/src/services/OtaAutoChain.ts +106 -0
  315. package/src/services/OtaErrorMapping.ts +80 -0
  316. package/src/services/OtaInstallCoordinator.ts +645 -143
  317. package/src/services/OtaService.ts +11 -5
  318. package/src/services/OtaUpdateCheckService.ts +180 -20
  319. package/src/services/PhoneCalendarService.ts +27 -4
  320. package/src/services/PhoneCameraFovCoordinator.ts +15 -0
  321. package/src/services/PhoneNotificationsSync.ts +12 -10
  322. package/src/services/PhonePhotoCoordinator.ts +33 -7
  323. package/src/services/PhoneStreamCoordinator.ts +139 -25
  324. package/src/services/PhoneVideoCoordinator.ts +11 -0
  325. package/src/services/SceneRenderer.ts +16 -0
  326. package/src/services/SupportProfileSync.ts +218 -0
  327. package/src/services/TtsTextSanitizer.ts +66 -0
  328. package/src/services/WifiAdbRequest.ts +53 -0
  329. package/src/services/asg/asgCameraApi.ts +99 -8
  330. package/src/services/asg/gallerySyncService.ts +28 -19
  331. package/src/services/asg/localNetworkTransport.ts +7 -11
  332. package/src/services/audioPlaybackAssets.ts +8 -0
  333. package/src/services/glassesClockSync.ts +2 -2
  334. package/src/services/manifestActions.ts +30 -0
  335. package/src/services/otaDisplayState.ts +57 -16
  336. package/src/services/otaInstallPolicy.ts +29 -3
  337. package/src/services/{asgOtaVersionUrl.ts → otaManifestUrl.ts} +16 -4
  338. package/src/services/slimStreamStatus.ts +16 -5
  339. package/src/stores/apps.ts +12 -0
  340. package/src/stores/bluetoothSettingKeys.ts +2 -0
  341. package/src/stores/gallerySync.ts +4 -0
  342. package/src/stores/glasses.ts +3 -0
  343. package/src/stores/settings.ts +174 -21
  344. package/src/types/applet.ts +2 -0
  345. package/src/types/enums.ts +2 -1
  346. package/src/types/hardware.ts +17 -0
  347. package/src/utils/configuredDevHost.ts +17 -0
  348. package/src/utils/devMiniappLaunch.ts +151 -23
  349. package/src/utils/diagnosticContext.ts +17 -17
  350. package/src/utils/hardware/hardware.ts +7 -9
  351. package/src/utils/miniappDiagnostics.ts +226 -0
  352. package/src/utils/timers/timers.ts +57 -21
  353. package/build/services/asgOtaVersionUrl.d.ts +0 -2
  354. package/build/services/asgOtaVersionUrl.d.ts.map +0 -1
  355. package/build/services/asgOtaVersionUrl.js.map +0 -1
@@ -58,6 +58,9 @@ const DEFAULT_TIMINGS = {
58
58
  ackTimeoutMs: 10_000,
59
59
  maxMissedAcks: 3,
60
60
  cloudflareStatusPollMs: 5_000,
61
+ // During WHIP startup, probe quickly so readiness is not quantized to the
62
+ // steady-state 5s monitoring cadence. The delay backs off on each miss.
63
+ cloudflareStartupPollInitialMs: 500,
61
64
  // Cloudflare typically needs ~5-10s after first frame before HLS is live.
62
65
  hlsReadinessInitialDelayMs: 5_000,
63
66
  hlsReadinessPollMs: 2_000,
@@ -85,6 +88,8 @@ export interface StartUnmanagedOptions {
85
88
  video?: StreamStartRequest["video"]
86
89
  audio?: StreamStartRequest["audio"]
87
90
  sound?: boolean
91
+ /** Optional Bearer token for WHIP Authorization (custom authenticated endpoints). */
92
+ authToken?: string
88
93
  }
89
94
 
90
95
  export interface StartManagedOptions {
@@ -99,8 +104,10 @@ export interface StartManagedOptions {
99
104
  * - "whip": WebRTC ingest -> WHEP playback (<1s glass-to-screen), but NO
100
105
  * HLS/DASH playback and NO recording (Cloudflare limitation; the returned
101
106
  * hlsUrl will serve 204 forever).
107
+ * - "rtmp": RTMPS ingest over TCP 443 -> HLS playback. Survives networks
108
+ * that drop WHIP/SRT UDP.
102
109
  */
103
- ingest?: "srt" | "whip"
110
+ ingest?: "srt" | "whip" | "rtmp"
104
111
  }
105
112
 
106
113
  export interface StreamPublisherStartResult {
@@ -152,11 +159,13 @@ interface ManagedEntry {
152
159
  hlsReady: boolean
153
160
  hlsReadyResolvers: Array<(result: ManagedStartResult) => void>
154
161
  hlsReadyRejecters: Array<(err: Error) => void>
155
- cloudflareTimer?: ReturnType<typeof setInterval>
162
+ cloudflareTimer?: ReturnType<typeof setTimeout>
156
163
  hlsTimer?: ReturnType<typeof setInterval>
157
164
  hlsAttempts: number
158
- /** webrtc mode: CF status polls seen while waiting for first "connected". */
159
- connectAttempts: number
165
+ /** Cloudflare status probes made during this stream session. */
166
+ cloudflareAttempts: number
167
+ /** Wall-clock origin for end-to-end managed startup diagnostics. */
168
+ startupStartedAtMs: number
160
169
  }
161
170
 
162
171
  type Entry = UnmanagedEntry | ManagedEntry
@@ -250,6 +259,26 @@ export class PhoneStreamCoordinator {
250
259
  return this.current !== null && this.current.streamId === streamId
251
260
  }
252
261
 
262
+ /** Report-safe stream ownership snapshot for incident diagnostics. */
263
+ getDiagnosticSnapshot(): Record<string, unknown> {
264
+ if (!this.current) return {active: false}
265
+ return this.current.kind === "managed"
266
+ ? {
267
+ active: true,
268
+ kind: this.current.kind,
269
+ streamId: this.current.streamId,
270
+ subscribers: [...this.current.subscribers].sort(),
271
+ mode: this.current.mode,
272
+ playbackReady: this.current.hlsReady,
273
+ }
274
+ : {
275
+ active: true,
276
+ kind: this.current.kind,
277
+ streamId: this.current.streamId,
278
+ ownerPackageName: this.current.packageName,
279
+ }
280
+ }
281
+
253
282
  async startUnmanaged(
254
283
  packageName: string,
255
284
  opts: StartUnmanagedOptions,
@@ -289,6 +318,7 @@ export class PhoneStreamCoordinator {
289
318
  // undefined, expected an Object") — only include what was provided.
290
319
  ...(opts.video !== undefined ? {video: opts.video} : {}),
291
320
  ...(opts.audio !== undefined ? {audio: opts.audio} : {}),
321
+ ...(opts.authToken ? {authToken: opts.authToken} : {}),
292
322
  })
293
323
  const result = publisherStartResult(streamId, event)
294
324
  this.startLifecycle(streamId)
@@ -304,6 +334,15 @@ export class PhoneStreamCoordinator {
304
334
  packageName: string,
305
335
  opts: StartManagedOptions,
306
336
  ): Promise<ManagedStartResult> {
337
+ const startupStartedAtMs = Date.now()
338
+ // streamId doesn't exist yet — it's minted a few lines below, once we
339
+ // know this is a fresh provision rather than a join onto an existing one.
340
+ console.info("[STREAM_STARTUP]", {
341
+ stage: "requested",
342
+ packageName,
343
+ ingest: opts.ingest,
344
+ elapsedMs: 0,
345
+ })
307
346
  // Two-phase: the entry-claim runs under the transition lock; the wait for
308
347
  // HLS readiness happens AFTER the lock releases so a long warm-up doesn't
309
348
  // block subsequent start/stop transitions on this coordinator.
@@ -362,10 +401,18 @@ export class PhoneStreamCoordinator {
362
401
  hlsReadyResolvers: [],
363
402
  hlsReadyRejecters: [],
364
403
  hlsAttempts: 0,
365
- connectAttempts: 0,
404
+ cloudflareAttempts: 0,
405
+ startupStartedAtMs,
366
406
  }
367
407
  this.current = entry
368
408
 
409
+ console.info("[STREAM_STARTUP]", {
410
+ streamId,
411
+ stage: "provisioned",
412
+ mode,
413
+ elapsedMs: Date.now() - startupStartedAtMs,
414
+ })
415
+
369
416
  try {
370
417
  const event = await BluetoothSdk.startExternallyManagedStream({
371
418
  type: "start_stream",
@@ -377,6 +424,12 @@ export class PhoneStreamCoordinator {
377
424
  ...(opts.audio !== undefined ? {audio: opts.audio} : {}),
378
425
  })
379
426
  entry.publisherStart = publisherStartResult(streamId, event)
427
+ console.info("[STREAM_STARTUP]", {
428
+ streamId,
429
+ stage: "publisher_ready",
430
+ mode,
431
+ elapsedMs: Date.now() - startupStartedAtMs,
432
+ })
380
433
  } catch (err) {
381
434
  this.current = null
382
435
  await teardownManagedStream(provision.liveInputId).catch(() => undefined)
@@ -398,6 +451,13 @@ export class PhoneStreamCoordinator {
398
451
  return decision.immediate
399
452
  }
400
453
 
454
+ // The first Cloudflare probe runs immediately and can complete before the
455
+ // transition lock releases. Avoid stranding this caller after readiness
456
+ // resolvers have already been drained.
457
+ if (decision.entry.hlsReady) {
458
+ return managedStartResult(decision.entry)
459
+ }
460
+
401
461
  // Wait for playback readiness OUTSIDE the lock — readiness can take ~10s
402
462
  // and we don't want to block other transitions for that long.
403
463
  return new Promise<ManagedStartResult>((resolve, reject) => {
@@ -530,20 +590,41 @@ export class PhoneStreamCoordinator {
530
590
  }
531
591
 
532
592
  private startCloudflareStatusPoll(entry: ManagedEntry): void {
533
- // webrtc mode: how many polls to wait for the first "connected" before
534
- // declaring the publisher never reached Cloudflare. Mirrors the HLS
535
- // readiness budget (~60s at the default 5s cadence).
536
- const maxConnectAttempts = Math.max(
537
- 1,
538
- Math.ceil(
539
- (this.timings.hlsReadinessMaxAttempts * this.timings.hlsReadinessPollMs) /
540
- this.timings.cloudflareStatusPollMs,
541
- ),
542
- )
593
+ // Keep the existing ~60s readiness budget while decoupling it from probe
594
+ // count. Startup probes begin immediately and back off to the normal 5s
595
+ // monitoring cadence, so a just-connected publisher is noticed quickly
596
+ // without increasing steady-state traffic.
597
+ const connectTimeoutMs = Math.max(1, this.timings.hlsReadinessMaxAttempts * this.timings.hlsReadinessPollMs)
598
+ const pollingStartedAtMs = Date.now()
599
+
600
+ const scheduleNext = () => {
601
+ if (this.current !== entry) return
602
+ const waitingForWebRtc = entry.mode === "webrtc" && !entry.hlsReady
603
+ const elapsedMs = Date.now() - pollingStartedAtMs
604
+ const remainingMs = Math.max(0, connectTimeoutMs - elapsedMs)
605
+ const startupDelayMs = Math.min(
606
+ this.timings.cloudflareStatusPollMs,
607
+ this.timings.cloudflareStartupPollInitialMs * 2 ** Math.min(Math.max(0, entry.cloudflareAttempts - 1), 10),
608
+ )
609
+ const delayMs = waitingForWebRtc ? Math.min(startupDelayMs, remainingMs) : this.timings.cloudflareStatusPollMs
610
+ entry.cloudflareTimer = setTimeout(() => void poll(), delayMs)
611
+ }
612
+
543
613
  const poll = async () => {
544
614
  if (this.current !== entry) return
615
+ const requestStartedAtMs = Date.now()
616
+ let keepPolling = true
617
+ entry.cloudflareAttempts += 1
545
618
  try {
546
619
  const status: CloudflareStatus = await getManagedStreamStatus(entry.liveInputId)
620
+ console.debug("[STREAM_STARTUP]", {
621
+ streamId: entry.streamId,
622
+ stage: "cloudflare_probe",
623
+ connected: status.isConnected,
624
+ attempt: entry.cloudflareAttempts,
625
+ requestMs: Date.now() - requestStartedAtMs,
626
+ elapsedMs: Date.now() - entry.startupStartedAtMs,
627
+ })
547
628
  this.fanout({
548
629
  streamId: entry.streamId,
549
630
  source: "cloudflare",
@@ -556,6 +637,13 @@ export class PhoneStreamCoordinator {
556
637
  if (entry.mode === "webrtc" && !entry.hlsReady) {
557
638
  if (status.isConnected) {
558
639
  entry.hlsReady = true
640
+ console.info("[STREAM_STARTUP]", {
641
+ streamId: entry.streamId,
642
+ stage: "playback_ready",
643
+ mode: entry.mode,
644
+ probes: entry.cloudflareAttempts,
645
+ elapsedMs: Date.now() - entry.startupStartedAtMs,
646
+ })
559
647
  const result = managedStartResult(entry)
560
648
  for (const r of entry.hlsReadyResolvers) r(result)
561
649
  entry.hlsReadyResolvers = []
@@ -567,11 +655,8 @@ export class PhoneStreamCoordinator {
567
655
  data: result as unknown as Record<string, unknown>,
568
656
  })
569
657
  } else {
570
- entry.connectAttempts += 1
571
- if (entry.connectAttempts >= maxConnectAttempts) {
572
- const err = new Error(
573
- `WebRTC ingest never reached Cloudflare after ${entry.connectAttempts} status polls`,
574
- )
658
+ if (Date.now() - pollingStartedAtMs >= connectTimeoutMs) {
659
+ const err = new Error(`WebRTC ingest never reached Cloudflare after ${connectTimeoutMs}ms`)
575
660
  for (const reject of entry.hlsReadyRejecters) reject(err)
576
661
  entry.hlsReadyResolvers = []
577
662
  entry.hlsReadyRejecters = []
@@ -586,15 +671,32 @@ export class PhoneStreamCoordinator {
586
671
  if (this.current?.streamId !== targetStreamId) return
587
672
  await this.teardownLocked("webrtc_not_connected")
588
673
  })
674
+ keepPolling = false
589
675
  }
590
676
  }
591
677
  }
592
678
  } catch (err) {
593
679
  console.warn("[STREAM] cloudflare status poll failed:", err)
680
+ if (entry.mode === "webrtc" && !entry.hlsReady && Date.now() - pollingStartedAtMs >= connectTimeoutMs) {
681
+ const timeoutErr = new Error(`WebRTC ingest status could not be confirmed after ${connectTimeoutMs}ms`)
682
+ for (const reject of entry.hlsReadyRejecters) reject(timeoutErr)
683
+ entry.hlsReadyResolvers = []
684
+ entry.hlsReadyRejecters = []
685
+ const targetStreamId = entry.streamId
686
+ void this.runExclusive(async () => {
687
+ if (this.current?.streamId !== targetStreamId) return
688
+ await this.teardownLocked("webrtc_status_unavailable")
689
+ })
690
+ keepPolling = false
691
+ }
692
+ } finally {
693
+ if (keepPolling) scheduleNext()
594
694
  }
595
695
  }
596
- entry.cloudflareTimer = setInterval(poll, this.timings.cloudflareStatusPollMs)
597
- // Don't fire immediately Cloudflare wouldn't have first-frame yet.
696
+
697
+ // The glasses publisher has already reported streaming, so the first
698
+ // status request is useful now. Most starts avoid the old blind 5s wait.
699
+ void poll()
598
700
  }
599
701
 
600
702
  private startHlsReadinessPoll(entry: ManagedEntry): void {
@@ -610,6 +712,13 @@ export class PhoneStreamCoordinator {
610
712
  const res = await fetch(entry.hlsUrl, {method: "HEAD"})
611
713
  if (res.status === 200) {
612
714
  entry.hlsReady = true
715
+ console.info("[STREAM_STARTUP]", {
716
+ streamId: entry.streamId,
717
+ stage: "playback_ready",
718
+ mode: entry.mode,
719
+ probes: entry.hlsAttempts,
720
+ elapsedMs: Date.now() - entry.startupStartedAtMs,
721
+ })
613
722
  if (entry.hlsTimer) {
614
723
  clearInterval(entry.hlsTimer)
615
724
  entry.hlsTimer = undefined
@@ -697,7 +806,7 @@ export class PhoneStreamCoordinator {
697
806
  this.lifecycle = null
698
807
 
699
808
  if (entry.kind === "managed") {
700
- if (entry.cloudflareTimer) clearInterval(entry.cloudflareTimer)
809
+ if (entry.cloudflareTimer) clearTimeout(entry.cloudflareTimer)
701
810
  if (entry.hlsTimer) clearInterval(entry.hlsTimer)
702
811
  // Reject any still-pending HLS readiness waiters.
703
812
  const pendingErr = new Error(`Stream torn down: ${reason}`)
@@ -728,7 +837,7 @@ export class PhoneStreamCoordinator {
728
837
  }
729
838
  }
730
839
 
731
- function pickIngestUrl(p: ProvisionResult, preference?: "srt" | "whip"): string {
840
+ function pickIngestUrl(p: ProvisionResult, preference?: "srt" | "whip" | "rtmp"): string {
732
841
  // Glasses' StreamCommandHandler detects protocol from URL prefix.
733
842
  //
734
843
  // Default priority: SRT > RTMP > WHIP. SRT first: Cloudflare's WebRTC (WHIP)
@@ -739,13 +848,18 @@ function pickIngestUrl(p: ProvisionResult, preference?: "srt" | "whip"): string
739
848
  //
740
849
  // "whip" preference flips the trade: sub-second WHEP playback for
741
850
  // live-monitor use cases, accepting no HLS and no recording.
851
+ // "rtmp" prefers TCP 443 ingest so networks that drop WHIP/SRT UDP still
852
+ // get HLS playback. Callers must request it explicitly; WHIP starts do not
853
+ // fall back to RTMP.
742
854
  //
743
855
  // Throw if none resolved so the caller's Promise rejects with a clear
744
856
  // message rather than the glasses' "unknown protocol" error.
745
857
  const url =
746
858
  preference === "whip"
747
859
  ? p.webrtcPublishUrl || p.srtUrl || p.rtmpUrl
748
- : p.srtUrl || p.rtmpUrl || p.webrtcPublishUrl
860
+ : preference === "rtmp"
861
+ ? p.rtmpUrl || p.srtUrl || p.webrtcPublishUrl
862
+ : p.srtUrl || p.rtmpUrl || p.webrtcPublishUrl
749
863
  if (!url) {
750
864
  throw new Error("Cloudflare provision returned no usable ingest URL")
751
865
  }
@@ -55,6 +55,17 @@ export class PhoneVideoCoordinator {
55
55
  // second start that races the first is still rejected.
56
56
  private starting = false
57
57
 
58
+ /** Report-safe recording ownership snapshot for incident diagnostics. */
59
+ getDiagnosticSnapshot(): Record<string, unknown> {
60
+ return {
61
+ recordingOwners: [
62
+ ...new Set([...this.activeRecordings.values()].map((recording) => recording.packageName)),
63
+ ].sort(),
64
+ activeRecordingCount: this.activeRecordings.size,
65
+ startInProgress: this.starting,
66
+ }
67
+ }
68
+
58
69
  async startRecording(packageName: string, opts: VideoRecordingOpts): Promise<VideoRecordingStarted> {
59
70
  // Fail fast if glasses aren't connected — the BLE command would otherwise
60
71
  // be sent into the void.
@@ -13,6 +13,7 @@
13
13
 
14
14
  import displayProcessor from "./DisplayProcessor"
15
15
  import BluetoothSdk from "@mentra/bluetooth-sdk/internal"
16
+ import {useDisplayStore} from "../stores/display"
16
17
  import {SETTINGS, useSettingsStore} from "../stores/settings"
17
18
  import {useGlassesStore} from "../stores/glasses"
18
19
  import {DeviceTypes, getModelCapabilities} from "../types"
@@ -269,6 +270,21 @@ class SceneRenderer {
269
270
  void Promise.resolve(BluetoothSdk.displayEvent({view: frame.view, scene: frame})).catch((err) => {
270
271
  console.error(`${LOG_TAG}: native scene send failed:`, err)
271
272
  })
273
+
274
+ // Keep the phone-side glasses mirror on the same frame as native. Legacy
275
+ // layouts update this store in LocalDisplayManager.sendToNative; scene
276
+ // frames bypass that method, so without an explicit write every
277
+ // positioning device (including G2) leaves the mirror blank/stale.
278
+ const caps = this.currentCapabilities()
279
+ useDisplayStore.getState().setDisplayEvent({
280
+ view: frame.view,
281
+ layout: {
282
+ layoutType: "scene",
283
+ width: caps?.width,
284
+ height: caps?.height,
285
+ elements: frame.elements,
286
+ },
287
+ })
272
288
  } catch (err) {
273
289
  console.error(`${LOG_TAG}: native scene send failed:`, err)
274
290
  }
@@ -0,0 +1,218 @@
1
+ import Constants from "expo-constants"
2
+ import * as Device from "expo-device"
3
+ import {Platform} from "react-native"
4
+ import type {SupportStateInput} from "@mentra/cloud-client"
5
+
6
+ import {useGlassesStore} from "../stores/glasses"
7
+ import {BgTimer} from "../utils/timers"
8
+ import {cloudClientService} from "./CloudClientService"
9
+
10
+ const ENGINE_VERSION = (require("../../package.json") as {version?: string}).version
11
+ const BLUETOOTH_SDK_VERSION = (require("@mentra/bluetooth-sdk/package.json") as {version?: string}).version
12
+ const DEBOUNCE_MS = 2_000
13
+ const RETRY_MS = 30_000
14
+ const HEARTBEAT_MS = 6 * 60 * 60_000
15
+
16
+ let unsubscribe: (() => void) | null = null
17
+ let debounceTimer: number | null = null
18
+ let retryTimer: number | null = null
19
+ let heartbeatTimer: number | null = null
20
+ let lastSentFingerprint: string | null = null
21
+ let queuedFingerprint: string | null = null
22
+ let sendingGeneration: number | null = null
23
+ let syncGeneration = 0
24
+ let consecutiveSendFailures = 0
25
+ let lastFailure: {code: string; stage: string} | null = null
26
+ let lastConnectionState: string | null = null
27
+
28
+ /**
29
+ * Keep Cloud V2's canonical support snapshot current without creating an event
30
+ * stream from noisy store updates. Only the explicitly selected fields below
31
+ * leave the phone; Wi-Fi, Bluetooth addresses, location and diagnostics do not.
32
+ */
33
+ export function startSupportProfileSync(): void {
34
+ if (unsubscribe) return
35
+ const generation = ++syncGeneration
36
+ lastConnectionState = useGlassesStore.getState().connection.state
37
+ unsubscribe = useGlassesStore.subscribe(() => handleGlassesStoreChange(generation))
38
+ heartbeatTimer = BgTimer.setInterval(() => void sendCurrentSnapshot(true, generation), HEARTBEAT_MS)
39
+ void sendCurrentSnapshot(true, generation)
40
+ }
41
+
42
+ export function stopSupportProfileSync(): void {
43
+ syncGeneration += 1
44
+ unsubscribe?.()
45
+ unsubscribe = null
46
+ if (debounceTimer !== null) BgTimer.clearTimeout(debounceTimer)
47
+ if (retryTimer !== null) BgTimer.clearTimeout(retryTimer)
48
+ if (heartbeatTimer !== null) BgTimer.clearInterval(heartbeatTimer)
49
+ debounceTimer = null
50
+ retryTimer = null
51
+ heartbeatTimer = null
52
+ lastSentFingerprint = null
53
+ queuedFingerprint = null
54
+ sendingGeneration = null
55
+ consecutiveSendFailures = 0
56
+ lastFailure = null
57
+ lastConnectionState = null
58
+ }
59
+
60
+ function handleGlassesStoreChange(generation: number): void {
61
+ if (!unsubscribe || generation !== syncGeneration) return
62
+ const connectionState = useGlassesStore.getState().connection.state
63
+ if (connectionState === "connected" && lastConnectionState !== "connected") lastFailure = null
64
+ lastConnectionState = connectionState
65
+ scheduleMeaningfulUpdate(generation)
66
+ }
67
+
68
+ function scheduleMeaningfulUpdate(generation = syncGeneration): void {
69
+ if (!unsubscribe || generation !== syncGeneration) return
70
+ const fingerprint = snapshotFingerprint(buildSnapshot())
71
+ if (fingerprint === lastSentFingerprint) return
72
+ queuedFingerprint = fingerprint
73
+ armDebounce(generation)
74
+ }
75
+
76
+ function armDebounce(generation: number): void {
77
+ if (debounceTimer !== null) BgTimer.clearTimeout(debounceTimer)
78
+ debounceTimer = BgTimer.setTimeout(() => {
79
+ debounceTimer = null
80
+ void sendCurrentSnapshot(false, generation)
81
+ }, DEBOUNCE_MS)
82
+ }
83
+
84
+ async function sendCurrentSnapshot(force: boolean, generation = syncGeneration): Promise<void> {
85
+ if (!unsubscribe || generation !== syncGeneration) return
86
+ if (sendingGeneration === generation) {
87
+ const fingerprint = snapshotFingerprint(buildSnapshot())
88
+ if (fingerprint !== lastSentFingerprint) queuedFingerprint = fingerprint
89
+ return
90
+ }
91
+ const snapshot = buildSnapshot()
92
+ const fingerprint = snapshotFingerprint(snapshot)
93
+ if (!force && fingerprint === lastSentFingerprint) return
94
+
95
+ sendingGeneration = generation
96
+ let retryDelayMs: number | null = null
97
+ try {
98
+ const result = await cloudClientService.core.supportProfile.update(snapshot)
99
+ if (generation !== syncGeneration || !unsubscribe) return
100
+ consecutiveSendFailures = 0
101
+ retryDelayMs = retryDelayForSupportProfileResult(result)
102
+ if (retryDelayMs === null) {
103
+ lastSentFingerprint = fingerprint
104
+ if (queuedFingerprint === fingerprint) queuedFingerprint = null
105
+ if (retryTimer !== null) BgTimer.clearTimeout(retryTimer)
106
+ retryTimer = null
107
+ }
108
+ } catch (error) {
109
+ if (generation !== syncGeneration || !unsubscribe) return
110
+ consecutiveSendFailures += 1
111
+ retryDelayMs = retryDelayForSendFailure(consecutiveSendFailures)
112
+ console.warn(
113
+ `supportProfile: Cloud V2 update failed (retry in ${Math.round(retryDelayMs / 1_000)}s):`,
114
+ error instanceof Error ? error.message : error,
115
+ )
116
+ } finally {
117
+ const generationIsActive = generation === syncGeneration && sendingGeneration === generation && Boolean(unsubscribe)
118
+ if (generationIsActive) {
119
+ sendingGeneration = null
120
+ if (retryDelayMs !== null) {
121
+ if (retryTimer !== null) BgTimer.clearTimeout(retryTimer)
122
+ retryTimer = BgTimer.setTimeout(() => {
123
+ retryTimer = null
124
+ void sendCurrentSnapshot(true, generation)
125
+ }, retryDelayMs)
126
+ } else {
127
+ const currentFingerprint = snapshotFingerprint(buildSnapshot())
128
+ if (currentFingerprint !== lastSentFingerprint) {
129
+ queuedFingerprint = currentFingerprint
130
+ if (retryTimer === null) armDebounce(generation)
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ export function retryDelayForSupportProfileResult(result: {
138
+ status: "accepted" | "deduplicated" | "stale"
139
+ }): number | null {
140
+ if (result.status === "accepted" || result.status === "deduplicated") return null
141
+ return RETRY_MS
142
+ }
143
+
144
+ /**
145
+ * Support freshness is not worth an aggressive retry loop. A snapshot the
146
+ * server keeps rejecting (skewed clock, deleted account, signed-out host)
147
+ * backs off exponentially until it converges on the heartbeat cadence.
148
+ */
149
+ export function retryDelayForSendFailure(consecutiveFailures: number): number {
150
+ return Math.min(HEARTBEAT_MS, RETRY_MS * 2 ** Math.min(Math.max(consecutiveFailures, 1) - 1, 10))
151
+ }
152
+
153
+ export function buildSnapshot(observedAt = new Date()): SupportStateInput {
154
+ const glasses = useGlassesStore.getState()
155
+ const device = {
156
+ hardwareId: optional(glasses.serialNumber),
157
+ model: optional(glasses.deviceModel),
158
+ androidVersion: optional(glasses.androidVersion),
159
+ firmwareVersion: optional(glasses.firmwareVersion),
160
+ mtkFirmwareVersion: optional(glasses.mtkFirmwareVersion),
161
+ besFirmwareVersion: optional(glasses.besFirmwareVersion),
162
+ appVersion: optional(glasses.appVersion),
163
+ buildNumber: optional(glasses.buildNumber),
164
+ }
165
+ const hasDevice = Object.values(device).some(Boolean)
166
+ const constants = Constants as typeof Constants & {
167
+ nativeAppVersion?: string | null
168
+ nativeBuildVersion?: string | null
169
+ }
170
+ return {
171
+ observedAt: observedAt.toISOString(),
172
+ host: {
173
+ appVersion: optional(constants.nativeAppVersion ?? Constants.expoConfig?.version),
174
+ appBuild: optional(constants.nativeBuildVersion),
175
+ engineVersion: optional(ENGINE_VERSION),
176
+ bluetoothSdkVersion: optional(BLUETOOTH_SDK_VERSION),
177
+ phonePlatform: Platform.OS === "ios" || Platform.OS === "android" ? Platform.OS : "unknown",
178
+ phoneModel: optional(Device.modelName),
179
+ phoneOsVersion: optional(
180
+ Platform.OS === "android"
181
+ ? String((Platform.constants as {Release?: string} | undefined)?.Release ?? Platform.Version)
182
+ : String(Platform.Version),
183
+ ),
184
+ connectionState: glasses.connection.state,
185
+ ...(lastFailure ? {failureCode: lastFailure.code, failureStage: lastFailure.stage} : {}),
186
+ },
187
+ ...(hasDevice ? {device} : {}),
188
+ }
189
+ }
190
+
191
+ /** Record only normalized connection failures; arbitrary exception messages never leave the phone. */
192
+ export function recordSupportProfileConnectionFailure(error: unknown, stage: string): void {
193
+ const rawCode =
194
+ typeof error === "object" && error && "code" in error ? String((error as {code?: unknown}).code ?? "") : ""
195
+ const message = error instanceof Error ? `${error.name} ${error.message}`.toLowerCase() : ""
196
+ const code = /^[a-zA-Z0-9_.-]{1,64}$/.test(rawCode)
197
+ ? rawCode.toLowerCase()
198
+ : message.includes("permission")
199
+ ? "permission_denied"
200
+ : message.includes("bluetooth") && (message.includes("off") || message.includes("unavailable"))
201
+ ? "bluetooth_unavailable"
202
+ : message.includes("not found") || message.includes("no default")
203
+ ? "device_not_found"
204
+ : message.includes("timeout")
205
+ ? "connect_timeout"
206
+ : "connect_failed"
207
+ lastFailure = {code, stage: stage.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 64)}
208
+ scheduleMeaningfulUpdate()
209
+ }
210
+
211
+ export function snapshotFingerprint(snapshot: SupportStateInput): string {
212
+ return JSON.stringify({host: snapshot.host, device: snapshot.device})
213
+ }
214
+
215
+ function optional(value: string | null | undefined): string | undefined {
216
+ const trimmed = value?.trim()
217
+ return trimmed ? trimmed.slice(0, 128) : undefined
218
+ }
@@ -0,0 +1,66 @@
1
+ const HTML_ENTITIES: Record<string, string> = {
2
+ "&amp;": "&",
3
+ "&lt;": "<",
4
+ "&gt;": ">",
5
+ "&quot;": '"',
6
+ "&#39;": "'",
7
+ }
8
+
9
+ /** Convert display-oriented text into plain words before TTS synthesis. */
10
+ export function sanitizeTtsText(text: string): string {
11
+ let speech = text.replace(/&(amp|lt|gt|quot|#39);/gi, (entity) => HTML_ENTITIES[entity.toLowerCase()] ?? entity)
12
+
13
+ speech = speech
14
+ .replace(/<say-as\b[^>]*>(.*?)<\/say-as>/gis, "$1")
15
+ .replace(/<\/?[a-z][a-z0-9-]*(?:\s[^<>]*?)?\s*\/?>/gi, " ")
16
+ .replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
17
+ .replace(/`([^`]*)`/g, "$1")
18
+ .replace(/(-?\d+(?:[.,]\d+)?)\s*(?:°\s*f\b|℉)/gi, "$1 degrees Fahrenheit")
19
+ .replace(/(-?\d+(?:[.,]\d+)?)\s*(?:°\s*c\b|℃)/gi, "$1 degrees Celsius")
20
+ .replace(/(-?\d+(?:[.,]\d+)?)\s*°/g, "$1 degrees")
21
+ .replace(/<=/g, " less than or equal to ")
22
+ .replace(/>=/g, " greater than or equal to ")
23
+ .replace(/</g, " less than ")
24
+ .replace(/>/g, " greater than ")
25
+ .replace(/[[\]{}()]/g, " ")
26
+ .replace(/(degrees Fahrenheit)\s+(-?\d+(?:[.,]\d+)? degrees Celsius)\b/gi, "$1 or $2")
27
+ .replace(/(degrees Celsius)\s+(-?\d+(?:[.,]\d+)? degrees Fahrenheit)\b/gi, "$1 or $2")
28
+ .replace(/&/g, " and ")
29
+ .replace(/\+/g, " plus ")
30
+ .replace(/=/g, " equals ")
31
+ .replace(/#/g, " hash ")
32
+ .replace(/@/g, " at ")
33
+ .replace(/%/g, " percent ")
34
+ .replace(/\\/g, " backslash ")
35
+ .replace(/\//g, (slash, offset, source) => {
36
+ const numericSeparator = /\d/.test(source[offset - 1] ?? "") && /\d/.test(source[offset + 1] ?? "")
37
+ return numericSeparator ? slash : " slash "
38
+ })
39
+ .replace(/\*/g, " star ")
40
+
41
+ return speech
42
+ .replace(/\s+([,.;:!?])/g, "$1")
43
+ .replace(/\s+/g, " ")
44
+ .trim()
45
+ }
46
+
47
+ /** Prepare an already-trimmed sentence list while preserving the exact-text opt-out. */
48
+ export function prepareTtsSentences(sentences: string[], enableSanitization = true): string[] {
49
+ if (!enableSanitization) return sentences
50
+
51
+ const sanitized = sentences.map((sentence) => sanitizeTtsText(sentence)).filter(Boolean)
52
+
53
+ return sanitized.map((sentence, index) => {
54
+ const previous = sanitized[index - 1]
55
+ if (!previous) return sentence
56
+
57
+ const followsFahrenheit = /degrees Fahrenheit[,.;:!?]?$/i.test(previous)
58
+ const followsCelsius = /degrees Celsius[,.;:!?]?$/i.test(previous)
59
+ const startsWithFahrenheit = /^-?\d+(?:[.,]\d+)? degrees Fahrenheit\b/i.test(sentence)
60
+ const startsWithCelsius = /^-?\d+(?:[.,]\d+)? degrees Celsius\b/i.test(sentence)
61
+
62
+ return (followsFahrenheit && startsWithCelsius) || (followsCelsius && startsWithFahrenheit)
63
+ ? `or ${sentence}`
64
+ : sentence
65
+ })
66
+ }