@mentra/miniapp 0.3.0-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/README.md +259 -0
  2. package/dist/background/index.d.ts +59 -0
  3. package/dist/background/index.d.ts.map +1 -0
  4. package/dist/background/index.js +25 -0
  5. package/dist/background/index.js.map +1 -0
  6. package/dist/background/register.d.ts +38 -0
  7. package/dist/background/register.d.ts.map +1 -0
  8. package/dist/background/register.js +75 -0
  9. package/dist/background/register.js.map +1 -0
  10. package/dist/dev-reload.d.ts +21 -0
  11. package/dist/dev-reload.d.ts.map +1 -0
  12. package/dist/dev-reload.js +62 -0
  13. package/dist/dev-reload.js.map +1 -0
  14. package/dist/envelope.d.ts +23 -0
  15. package/dist/envelope.d.ts.map +1 -0
  16. package/dist/envelope.js +47 -0
  17. package/dist/envelope.js.map +1 -0
  18. package/dist/globals.d.ts +54 -0
  19. package/dist/globals.d.ts.map +1 -0
  20. package/dist/globals.js +12 -0
  21. package/dist/globals.js.map +1 -0
  22. package/dist/index.d.ts +58 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +31 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/modules/actions.d.ts +54 -0
  27. package/dist/modules/actions.d.ts.map +1 -0
  28. package/dist/modules/actions.js +122 -0
  29. package/dist/modules/actions.js.map +1 -0
  30. package/dist/modules/auth.d.ts +25 -0
  31. package/dist/modules/auth.d.ts.map +1 -0
  32. package/dist/modules/auth.js +52 -0
  33. package/dist/modules/auth.js.map +1 -0
  34. package/dist/modules/base64.d.ts +16 -0
  35. package/dist/modules/base64.d.ts.map +1 -0
  36. package/dist/modules/base64.js +92 -0
  37. package/dist/modules/base64.js.map +1 -0
  38. package/dist/modules/blob.d.ts +161 -0
  39. package/dist/modules/blob.d.ts.map +1 -0
  40. package/dist/modules/blob.js +277 -0
  41. package/dist/modules/blob.js.map +1 -0
  42. package/dist/modules/camera.d.ts +137 -0
  43. package/dist/modules/camera.d.ts.map +1 -0
  44. package/dist/modules/camera.js +104 -0
  45. package/dist/modules/camera.js.map +1 -0
  46. package/dist/modules/cloud.d.ts +34 -0
  47. package/dist/modules/cloud.d.ts.map +1 -0
  48. package/dist/modules/cloud.js +70 -0
  49. package/dist/modules/cloud.js.map +1 -0
  50. package/dist/modules/dashboard.d.ts +16 -0
  51. package/dist/modules/dashboard.d.ts.map +1 -0
  52. package/dist/modules/dashboard.js +27 -0
  53. package/dist/modules/dashboard.js.map +1 -0
  54. package/dist/modules/display.d.ts +106 -0
  55. package/dist/modules/display.d.ts.map +1 -0
  56. package/dist/modules/display.js +57 -0
  57. package/dist/modules/display.js.map +1 -0
  58. package/dist/modules/events.d.ts +167 -0
  59. package/dist/modules/events.d.ts.map +1 -0
  60. package/dist/modules/events.js +123 -0
  61. package/dist/modules/events.js.map +1 -0
  62. package/dist/modules/glasses.d.ts +28 -0
  63. package/dist/modules/glasses.d.ts.map +1 -0
  64. package/dist/modules/glasses.js +39 -0
  65. package/dist/modules/glasses.js.map +1 -0
  66. package/dist/modules/heading.d.ts +16 -0
  67. package/dist/modules/heading.d.ts.map +1 -0
  68. package/dist/modules/heading.js +20 -0
  69. package/dist/modules/heading.js.map +1 -0
  70. package/dist/modules/imu.d.ts +31 -0
  71. package/dist/modules/imu.d.ts.map +1 -0
  72. package/dist/modules/imu.js +40 -0
  73. package/dist/modules/imu.js.map +1 -0
  74. package/dist/modules/input.d.ts +34 -0
  75. package/dist/modules/input.d.ts.map +1 -0
  76. package/dist/modules/input.js +50 -0
  77. package/dist/modules/input.js.map +1 -0
  78. package/dist/modules/led.d.ts +35 -0
  79. package/dist/modules/led.d.ts.map +1 -0
  80. package/dist/modules/led.js +39 -0
  81. package/dist/modules/led.js.map +1 -0
  82. package/dist/modules/location.d.ts +27 -0
  83. package/dist/modules/location.d.ts.map +1 -0
  84. package/dist/modules/location.js +35 -0
  85. package/dist/modules/location.js.map +1 -0
  86. package/dist/modules/mic.d.ts +44 -0
  87. package/dist/modules/mic.d.ts.map +1 -0
  88. package/dist/modules/mic.js +67 -0
  89. package/dist/modules/mic.js.map +1 -0
  90. package/dist/modules/miniapps.d.ts +72 -0
  91. package/dist/modules/miniapps.d.ts.map +1 -0
  92. package/dist/modules/miniapps.js +48 -0
  93. package/dist/modules/miniapps.js.map +1 -0
  94. package/dist/modules/navigation.d.ts +326 -0
  95. package/dist/modules/navigation.d.ts.map +1 -0
  96. package/dist/modules/navigation.js +386 -0
  97. package/dist/modules/navigation.js.map +1 -0
  98. package/dist/modules/permissions.d.ts +58 -0
  99. package/dist/modules/permissions.d.ts.map +1 -0
  100. package/dist/modules/permissions.js +66 -0
  101. package/dist/modules/permissions.js.map +1 -0
  102. package/dist/modules/phone.d.ts +61 -0
  103. package/dist/modules/phone.d.ts.map +1 -0
  104. package/dist/modules/phone.js +94 -0
  105. package/dist/modules/phone.js.map +1 -0
  106. package/dist/modules/pivots/engine.d.ts +169 -0
  107. package/dist/modules/pivots/engine.d.ts.map +1 -0
  108. package/dist/modules/pivots/engine.js +779 -0
  109. package/dist/modules/pivots/engine.js.map +1 -0
  110. package/dist/modules/pivots/geometry.d.ts +84 -0
  111. package/dist/modules/pivots/geometry.d.ts.map +1 -0
  112. package/dist/modules/pivots/geometry.js +371 -0
  113. package/dist/modules/pivots/geometry.js.map +1 -0
  114. package/dist/modules/pivots/instructions.d.ts +139 -0
  115. package/dist/modules/pivots/instructions.d.ts.map +1 -0
  116. package/dist/modules/pivots/instructions.js +280 -0
  117. package/dist/modules/pivots/instructions.js.map +1 -0
  118. package/dist/modules/speaker.d.ts +163 -0
  119. package/dist/modules/speaker.d.ts.map +1 -0
  120. package/dist/modules/speaker.js +250 -0
  121. package/dist/modules/speaker.js.map +1 -0
  122. package/dist/modules/storage.d.ts +50 -0
  123. package/dist/modules/storage.d.ts.map +1 -0
  124. package/dist/modules/storage.js +99 -0
  125. package/dist/modules/storage.js.map +1 -0
  126. package/dist/modules/stream.d.ts +129 -0
  127. package/dist/modules/stream.d.ts.map +1 -0
  128. package/dist/modules/stream.js +52 -0
  129. package/dist/modules/stream.js.map +1 -0
  130. package/dist/modules/system.d.ts +45 -0
  131. package/dist/modules/system.d.ts.map +1 -0
  132. package/dist/modules/system.js +42 -0
  133. package/dist/modules/system.js.map +1 -0
  134. package/dist/modules/transcription.d.ts +67 -0
  135. package/dist/modules/transcription.d.ts.map +1 -0
  136. package/dist/modules/transcription.js +106 -0
  137. package/dist/modules/transcription.js.map +1 -0
  138. package/dist/modules/translation.d.ts +59 -0
  139. package/dist/modules/translation.d.ts.map +1 -0
  140. package/dist/modules/translation.js +119 -0
  141. package/dist/modules/translation.js.map +1 -0
  142. package/dist/modules/ui.d.ts +186 -0
  143. package/dist/modules/ui.d.ts.map +1 -0
  144. package/dist/modules/ui.js +293 -0
  145. package/dist/modules/ui.js.map +1 -0
  146. package/dist/protocol.d.ts +294 -0
  147. package/dist/protocol.d.ts.map +1 -0
  148. package/dist/protocol.js +320 -0
  149. package/dist/protocol.js.map +1 -0
  150. package/dist/react/MentraProvider.d.ts +33 -0
  151. package/dist/react/MentraProvider.d.ts.map +1 -0
  152. package/dist/react/MentraProvider.js +15 -0
  153. package/dist/react/MentraProvider.js.map +1 -0
  154. package/dist/react/MiniappHeader.d.ts +44 -0
  155. package/dist/react/MiniappHeader.d.ts.map +1 -0
  156. package/dist/react/MiniappHeader.js +32 -0
  157. package/dist/react/MiniappHeader.js.map +1 -0
  158. package/dist/react/index.d.ts +13 -0
  159. package/dist/react/index.d.ts.map +1 -0
  160. package/dist/react/index.js +9 -0
  161. package/dist/react/index.js.map +1 -0
  162. package/dist/react/useCapabilities.d.ts +26 -0
  163. package/dist/react/useCapabilities.d.ts.map +1 -0
  164. package/dist/react/useCapabilities.js +27 -0
  165. package/dist/react/useCapabilities.js.map +1 -0
  166. package/dist/react/useCapsuleHeaderStyle.d.ts +27 -0
  167. package/dist/react/useCapsuleHeaderStyle.d.ts.map +1 -0
  168. package/dist/react/useCapsuleHeaderStyle.js +36 -0
  169. package/dist/react/useCapsuleHeaderStyle.js.map +1 -0
  170. package/dist/react/useColorScheme.d.ts +18 -0
  171. package/dist/react/useColorScheme.d.ts.map +1 -0
  172. package/dist/react/useColorScheme.js +24 -0
  173. package/dist/react/useColorScheme.js.map +1 -0
  174. package/dist/react/useConnected.d.ts +17 -0
  175. package/dist/react/useConnected.d.ts.map +1 -0
  176. package/dist/react/useConnected.js +39 -0
  177. package/dist/react/useConnected.js.map +1 -0
  178. package/dist/react/useRpc.d.ts +29 -0
  179. package/dist/react/useRpc.d.ts.map +1 -0
  180. package/dist/react/useRpc.js +82 -0
  181. package/dist/react/useRpc.js.map +1 -0
  182. package/dist/react/useSafeArea.d.ts +21 -0
  183. package/dist/react/useSafeArea.d.ts.map +1 -0
  184. package/dist/react/useSafeArea.js +22 -0
  185. package/dist/react/useSafeArea.js.map +1 -0
  186. package/dist/session.d.ts +310 -0
  187. package/dist/session.d.ts.map +1 -0
  188. package/dist/session.js +653 -0
  189. package/dist/session.js.map +1 -0
  190. package/dist/sub-path-types.test-d.d.ts +28 -0
  191. package/dist/sub-path-types.test-d.d.ts.map +1 -0
  192. package/dist/sub-path-types.test-d.js +37 -0
  193. package/dist/sub-path-types.test-d.js.map +1 -0
  194. package/dist/transport/auto.d.ts +35 -0
  195. package/dist/transport/auto.d.ts.map +1 -0
  196. package/dist/transport/auto.js +125 -0
  197. package/dist/transport/auto.js.map +1 -0
  198. package/dist/transport/dispatch.d.ts +43 -0
  199. package/dist/transport/dispatch.d.ts.map +1 -0
  200. package/dist/transport/dispatch.js +91 -0
  201. package/dist/transport/dispatch.js.map +1 -0
  202. package/dist/transport/local-socket.d.ts +28 -0
  203. package/dist/transport/local-socket.d.ts.map +1 -0
  204. package/dist/transport/local-socket.js +80 -0
  205. package/dist/transport/local-socket.js.map +1 -0
  206. package/dist/transport/mock.d.ts +54 -0
  207. package/dist/transport/mock.d.ts.map +1 -0
  208. package/dist/transport/mock.js +205 -0
  209. package/dist/transport/mock.js.map +1 -0
  210. package/dist/transport/postmessage.d.ts +36 -0
  211. package/dist/transport/postmessage.d.ts.map +1 -0
  212. package/dist/transport/postmessage.js +78 -0
  213. package/dist/transport/postmessage.js.map +1 -0
  214. package/dist/transport/types.d.ts +25 -0
  215. package/dist/transport/types.d.ts.map +1 -0
  216. package/dist/transport/types.js +11 -0
  217. package/dist/transport/types.js.map +1 -0
  218. package/dist/ui/index.d.ts +98 -0
  219. package/dist/ui/index.d.ts.map +1 -0
  220. package/dist/ui/index.js +32 -0
  221. package/dist/ui/index.js.map +1 -0
  222. package/package.json +75 -0
  223. package/src/background/index.ts +152 -0
  224. package/src/background/register.ts +85 -0
  225. package/src/dev-reload.ts +60 -0
  226. package/src/envelope.ts +54 -0
  227. package/src/globals.ts +62 -0
  228. package/src/index.ts +171 -0
  229. package/src/modules/actions.ts +156 -0
  230. package/src/modules/auth.ts +68 -0
  231. package/src/modules/base64.ts +87 -0
  232. package/src/modules/blob.ts +341 -0
  233. package/src/modules/camera.ts +196 -0
  234. package/src/modules/cloud.ts +94 -0
  235. package/src/modules/dashboard.ts +31 -0
  236. package/src/modules/display.ts +122 -0
  237. package/src/modules/events.ts +264 -0
  238. package/src/modules/glasses.ts +44 -0
  239. package/src/modules/heading.ts +23 -0
  240. package/src/modules/imu.ts +44 -0
  241. package/src/modules/input.ts +71 -0
  242. package/src/modules/led.ts +61 -0
  243. package/src/modules/location.ts +39 -0
  244. package/src/modules/mic.ts +74 -0
  245. package/src/modules/miniapps.ts +90 -0
  246. package/src/modules/navigation.ts +670 -0
  247. package/src/modules/permissions.ts +84 -0
  248. package/src/modules/phone.ts +120 -0
  249. package/src/modules/pivots/engine.ts +863 -0
  250. package/src/modules/pivots/geometry.ts +409 -0
  251. package/src/modules/pivots/instructions.ts +343 -0
  252. package/src/modules/speaker.ts +328 -0
  253. package/src/modules/storage.ts +109 -0
  254. package/src/modules/stream.ts +163 -0
  255. package/src/modules/system.ts +76 -0
  256. package/src/modules/transcription.ts +128 -0
  257. package/src/modules/translation.ts +149 -0
  258. package/src/modules/ui.ts +498 -0
  259. package/src/protocol.ts +371 -0
  260. package/src/react/MentraProvider.tsx +50 -0
  261. package/src/react/MiniappHeader.tsx +122 -0
  262. package/src/react/index.ts +12 -0
  263. package/src/react/useCapabilities.ts +29 -0
  264. package/src/react/useCapsuleHeaderStyle.ts +54 -0
  265. package/src/react/useColorScheme.ts +26 -0
  266. package/src/react/useConnected.ts +47 -0
  267. package/src/react/useRpc.ts +106 -0
  268. package/src/react/useSafeArea.ts +40 -0
  269. package/src/session.ts +878 -0
  270. package/src/sub-path-types.test-d.ts +49 -0
  271. package/src/transport/auto.ts +143 -0
  272. package/src/transport/dispatch.ts +106 -0
  273. package/src/transport/local-socket.ts +93 -0
  274. package/src/transport/mock.ts +248 -0
  275. package/src/transport/postmessage.ts +95 -0
  276. package/src/transport/types.ts +26 -0
  277. package/src/ui/index.ts +122 -0
package/src/session.ts ADDED
@@ -0,0 +1,878 @@
1
+ /**
2
+ * @fileoverview MiniappSession — central session object for a local miniapp.
3
+ *
4
+ * Owns the transport, the request/response correlation map, the readiness queue,
5
+ * the PONG auto-reply, the visibility state, and all per-module instances.
6
+ *
7
+ * Lifecycle:
8
+ * const session = new MiniappSession()
9
+ * await session.connect() // sends CONNECT, resolves on CONNECT_ACK
10
+ * session.display.render([...])
11
+ * ...
12
+ * session.disconnect()
13
+ */
14
+
15
+ import {EventEmitter} from "eventemitter3"
16
+
17
+ import {makeRequestId, MiniappEnvelope, parseEnvelope, serializeEnvelope} from "./envelope"
18
+ import {getMentraOSGlobals, MiniappColorScheme} from "./globals"
19
+ import {MiniappErrorCode, MiniappRequestType, MiniappResponseType} from "./protocol"
20
+ import {createTransport, CreateTransportOptions} from "./transport/auto"
21
+ import {Transport} from "./transport/types"
22
+ import {CameraModule} from "./modules/camera"
23
+ import {AuthModule} from "./modules/auth"
24
+ import {CloudModule} from "./modules/cloud"
25
+ import {DashboardAPI} from "./modules/dashboard"
26
+ import {DisplayManager} from "./modules/display"
27
+ import {EventManager, type UnsubscribeFn} from "./modules/events"
28
+ import {GlassesModule} from "./modules/glasses"
29
+ import {HeadingModule} from "./modules/heading"
30
+ import {ImuModule} from "./modules/imu"
31
+ import {InputModule} from "./modules/input"
32
+ import {LedModule} from "./modules/led"
33
+ import {LocationModule} from "./modules/location"
34
+ import {MicModule} from "./modules/mic"
35
+ import {NavigationModule} from "./modules/navigation"
36
+ import {PermissionsModule} from "./modules/permissions"
37
+ import {PhoneModule} from "./modules/phone"
38
+ import {TranscriptionModule} from "./modules/transcription"
39
+ import {TranslationModule} from "./modules/translation"
40
+ import {UIModuleImpl, type UIModule} from "./modules/ui"
41
+ import {SimpleStorage} from "./modules/storage"
42
+ import {SpeakerModule} from "./modules/speaker"
43
+ import {StreamModule} from "./modules/stream"
44
+ import {SystemModule} from "./modules/system"
45
+ import {MiniappsModule} from "./modules/miniapps"
46
+ import {ActionsModule} from "./modules/actions"
47
+ import {BlobModule} from "./modules/blob"
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Public types
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Typed display capabilities for the scene API. All limit fields are optional
55
+ * in the type because older hosts don't send them — treat absence as "unknown",
56
+ * not zero. Populated on the "ready" event (null in `start()`).
57
+ */
58
+ export interface DisplayCapabilities {
59
+ /** Public drawable canvas in px — raw coordinate space for `display.render()` boxes. */
60
+ width?: number
61
+ height?: number
62
+ /** False ⇒ the device can't position elements; scenes degrade to text walls host-side. */
63
+ canPosition?: boolean
64
+ /** Element budgets. Rects share the text pool on container-based devices. */
65
+ maxTextElements?: number
66
+ maxImageElements?: number
67
+ /** Per-image dimension cap (box-level), when the device has one. */
68
+ maxImagePx?: {width: number; height: number}
69
+ shapes?: string[]
70
+ intensityLevels?: number
71
+ partialUpdate?: boolean
72
+ /** Legacy capability fields (resolution, isColor, maxTextLines, …) ride along. */
73
+ [key: string]: unknown
74
+ }
75
+
76
+ /** Minimal snapshot of the currently-connected glasses. Phone-provided. */
77
+ export interface GlassesCapabilities {
78
+ /** Display block — null/absent on displayless devices (e.g. Mentra Live). */
79
+ display?: DisplayCapabilities | null
80
+ [key: string]: unknown
81
+ }
82
+
83
+ export type MiniappVisibility = "foreground" | "background"
84
+
85
+ export interface MiniappSessionOptions extends CreateTransportOptions {
86
+ /** Override auto-detected packageName. Normally provided via window.MentraOS. */
87
+ packageName?: string
88
+ /** Override the ready timeout. Default 10s. */
89
+ connectTimeoutMs?: number
90
+ }
91
+
92
+ export interface ConnectAckPayload {
93
+ type: MiniappResponseType.CONNECT_ACK
94
+ userId: string
95
+ packageName: string
96
+ capabilities: GlassesCapabilities | null
97
+ visibility?: MiniappVisibility
98
+ colorScheme?: MiniappColorScheme
99
+ /**
100
+ * Manifest-declared permission record. Mirrors cloud SDK v3's PermissionRecord:
101
+ * `{location, microphone, camera, notifications, calendar}` — booleans
102
+ * indicating whether the miniapp's manifest declared each. This is
103
+ * declaration-only; OS-grant state is not modeled.
104
+ */
105
+ permissions?: PermissionRecord
106
+ /** Miniapp-scoped backend auth. Never a Core or runtime token. */
107
+ auth?: MiniappAuthState
108
+ }
109
+
110
+ export interface MiniappAuthState {
111
+ mentraUserId: string
112
+ oemId?: string
113
+ token: string
114
+ expiresAt: number
115
+ }
116
+
117
+ export interface AuthUpdatePayload {
118
+ type: MiniappResponseType.AUTH_UPDATE
119
+ auth?: MiniappAuthState
120
+ }
121
+
122
+ interface AuthRefreshResult {
123
+ auth?: MiniappAuthState
124
+ }
125
+
126
+ /**
127
+ * Manifest-declared permission record. v3-aligned: lowercase canonical keys.
128
+ * Booleans indicate whether the miniapp declared each in its manifest.json.
129
+ */
130
+ export type PermissionType = "location" | "microphone" | "camera" | "notifications" | "calendar"
131
+ export type PermissionRecord = Record<PermissionType, boolean>
132
+
133
+ const ALL_PERMISSION_TYPES: readonly PermissionType[] = [
134
+ "location",
135
+ "microphone",
136
+ "camera",
137
+ "notifications",
138
+ "calendar",
139
+ ] as const
140
+
141
+ export class NotConnectedError extends Error {
142
+ readonly code = MiniappErrorCode.NOT_CONNECTED
143
+ constructor(message = "MiniappSession is not connected") {
144
+ super(message)
145
+ this.name = "NotConnectedError"
146
+ }
147
+ }
148
+
149
+ export interface MiniappRequestError {
150
+ code: string
151
+ message: string
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Implementation
156
+ // ---------------------------------------------------------------------------
157
+
158
+ interface PendingRequest {
159
+ requestId: string
160
+ resolve: (value: unknown) => void
161
+ reject: (error: MiniappRequestError) => void
162
+ /** Timeout handle; cleared when the response (or a transport failure) settles the request. */
163
+ timer?: ReturnType<typeof setTimeout>
164
+ }
165
+
166
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
167
+
168
+ // Hard ceiling on how long a single bridge request waits for the host's
169
+ // REQUEST_RESULT. Without it, a host that never responds (a hung cloud call, a
170
+ // GPS fix that never arrives, a native handler that stalls) leaves the request
171
+ // promise pending FOREVER — which is what stalled navigation at "Starting…"
172
+ // (the controller's `starting` guard never reset because start() never settled).
173
+ // 60s is generous: it covers a slow route computation while still guaranteeing
174
+ // the promise eventually rejects so callers can roll back / surface an error.
175
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
176
+
177
+ type SessionEmitterEvents = {
178
+ ready: () => void
179
+ error: (error: Error) => void
180
+ /**
181
+ * Last-chance hook before the transport closes. Fires when the phone
182
+ * sends WILL_DISCONNECT, or when this session calls `disconnect()`
183
+ * locally. Handlers run synchronously and may issue one final
184
+ * `sendOneShot`/`sendRequest` (e.g. `display.render([])`); async work won't complete
185
+ * before the socket closes.
186
+ */
187
+ beforeDisconnect: (reason: string) => void
188
+ disconnect: (reason: string) => void
189
+ visibility: (v: MiniappVisibility) => void
190
+ capabilities: (cap: GlassesCapabilities | null) => void
191
+ colorScheme: (scheme: MiniappColorScheme) => void
192
+ permissions: (perms: PermissionRecord) => void
193
+ speakerState: (event: import("./modules/speaker").SpeakerStateEvent) => void
194
+ auth: (auth: MiniappAuthState) => void
195
+ }
196
+
197
+ export class MiniappSession {
198
+ public readonly auth: AuthModule
199
+ public readonly display: DisplayManager
200
+ /**
201
+ * Internal subscription registry + escape hatch.
202
+ *
203
+ * Domain modules (`session.mic`, `session.input`, etc.) are the canonical
204
+ * surface for typed event subscriptions. `events.subscribe(...)` remains as
205
+ * a forward-compat escape hatch for new event types not yet wrapped on a
206
+ * domain module.
207
+ */
208
+ public readonly events: EventManager
209
+ public readonly speaker: SpeakerModule
210
+ public readonly camera: CameraModule
211
+ public readonly cloud: CloudModule
212
+ public readonly dashboard: DashboardAPI
213
+ public readonly glasses: GlassesModule
214
+ public readonly heading: HeadingModule
215
+ public readonly imu: ImuModule
216
+ public readonly input: InputModule
217
+ public readonly led: LedModule
218
+ public readonly location: LocationModule
219
+ public readonly mic: MicModule
220
+ public readonly navigation: NavigationModule
221
+ public readonly permissions: PermissionsModule
222
+ public readonly phone: PhoneModule
223
+ public readonly storage: SimpleStorage
224
+ /**
225
+ * Phone-local persistent BINARY storage (`session.blob`) — the binary
226
+ * counterpart to `session.storage`. Files on disk, scoped to this miniapp.
227
+ * Writes/reads are chunked so large payloads (e.g. captured audio fed in via
228
+ * `session.mic.onAudioChunk`) never cross the bridge in one message.
229
+ */
230
+ public readonly blob: BlobModule
231
+ public readonly stream: StreamModule
232
+ public readonly system: SystemModule
233
+ public readonly transcription: TranscriptionModule
234
+ public readonly translation: TranslationModule
235
+ /**
236
+ * UI message bus to the bound WebView (when one is open).
237
+ * Background-only API surface; mirrors the WebView's `mentra` global
238
+ * with inverted buffering policy (background drops when no WebView is
239
+ * bound; the WebView buffers until ready).
240
+ */
241
+ public readonly ui: UIModule
242
+ /**
243
+ * Inter-miniapp lifecycle + discovery (list / start / stop). SYSTEM-only —
244
+ * calls reject with NOT_PERMITTED unless this miniapp is a system app.
245
+ */
246
+ public readonly miniapps: MiniappsModule
247
+ /**
248
+ * Inter-miniapp action layer. `invoke` (SYSTEM-only) calls another miniapp's
249
+ * declared action; `handle` (open to all) exposes one of your own.
250
+ */
251
+ public readonly actions: ActionsModule
252
+
253
+ /** Phone-declared glasses capabilities. Null until CONNECT_ACK arrives. */
254
+ public capabilities: GlassesCapabilities | null = null
255
+ public userId = ""
256
+ public packageName = ""
257
+ public visibility: MiniappVisibility = "foreground"
258
+ /** Host color scheme. Seeded from window.MentraOS, updated via session events. */
259
+ public colorScheme: MiniappColorScheme = "light"
260
+
261
+ /** True after CONNECT_ACK. Observe with waitForReady() or the "ready" event. */
262
+ public ready = false
263
+
264
+ private readonly transport: Transport
265
+ private readonly connectTimeoutMs: number
266
+ private readonly emitter = new EventEmitter<SessionEmitterEvents>()
267
+ private authState: MiniappAuthState | null = null
268
+ private readonly authWaiters = new Set<{
269
+ minTtlMs: number
270
+ resolve: (auth: MiniappAuthState) => void
271
+ reject: (error: Error) => void
272
+ timer: ReturnType<typeof setTimeout>
273
+ }>()
274
+ private authRefreshPromise: Promise<MiniappAuthState | null> | null = null
275
+
276
+ /**
277
+ * Outbound queue for anything sent before CONNECT_ACK. Flushed in FIFO order
278
+ * once the phone responds with CONNECT_ACK.
279
+ */
280
+ private readonly outboundQueue: string[] = []
281
+ private readonly pendingRequests = new Map<string, PendingRequest>()
282
+ private connectPromise: Promise<void> | null = null
283
+ private disposed = false
284
+
285
+ /** Manifest-declared permission cache. Updated on CONNECT_ACK / PERMISSIONS_UPDATE. */
286
+ private _permissions: PermissionRecord = {
287
+ location: false,
288
+ microphone: false,
289
+ camera: false,
290
+ notifications: false,
291
+ calendar: false,
292
+ }
293
+
294
+ constructor(options: MiniappSessionOptions = {}) {
295
+ this.transport = createTransport(options)
296
+ this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
297
+
298
+ const injected = getMentraOSGlobals()
299
+ this.packageName = options.packageName ?? injected.packageName ?? ""
300
+ if (injected.colorScheme === "light" || injected.colorScheme === "dark") {
301
+ this.colorScheme = injected.colorScheme
302
+ }
303
+
304
+ this.auth = new AuthModule(this)
305
+ this.events = new EventManager(this)
306
+ this.speaker = new SpeakerModule(this)
307
+ this.camera = new CameraModule(this)
308
+ this.cloud = new CloudModule(this)
309
+ this.dashboard = new DashboardAPI(this)
310
+ this.display = new DisplayManager(this)
311
+ this.glasses = new GlassesModule(this)
312
+ this.heading = new HeadingModule(this)
313
+ this.imu = new ImuModule(this)
314
+ this.input = new InputModule(this)
315
+ this.led = new LedModule(this)
316
+ this.location = new LocationModule(this)
317
+ this.mic = new MicModule(this)
318
+ this.navigation = new NavigationModule(this)
319
+ this.permissions = new PermissionsModule(this)
320
+ this.phone = new PhoneModule(this)
321
+ this.storage = new SimpleStorage(this)
322
+ this.blob = new BlobModule(this)
323
+ this.stream = new StreamModule(this)
324
+ this.system = new SystemModule(this)
325
+ this.transcription = new TranscriptionModule(this)
326
+ this.translation = new TranslationModule(this)
327
+ this.ui = new UIModuleImpl(this)
328
+ this.miniapps = new MiniappsModule(this)
329
+ this.actions = new ActionsModule(this)
330
+ }
331
+
332
+ /**
333
+ * @internal — synchronous lookup against the cached manifest-declared
334
+ * permission record from CONNECT_ACK / PERMISSIONS_UPDATE. Domain modules
335
+ * use this to expose their `hasPermission` getters without going to the
336
+ * wire. Returns false until CONNECT_ACK arrives.
337
+ *
338
+ * `manifestKey` is the manifest's UPPER_CASE permission name
339
+ * (MICROPHONE, CAMERA, LOCATION, READ_NOTIFICATIONS, etc.). Maps to v3's
340
+ * lowercase canonical keys internally.
341
+ */
342
+ _hasManifestPermission(manifestKey: string): boolean {
343
+ const canonical = manifestKeyToCanonical(manifestKey)
344
+ if (!canonical) return false
345
+ return this._permissions[canonical] === true
346
+ }
347
+
348
+ /**
349
+ * @internal — read the current manifest-declared permission record.
350
+ * Powers session.permissions.getAll(). Returns a fresh shallow copy so
351
+ * callers can't mutate internal state.
352
+ */
353
+ _getPermissions(): PermissionRecord {
354
+ return {...this._permissions}
355
+ }
356
+
357
+ /** @internal — current miniapp-scoped backend auth, if the host provided one. */
358
+ _getAuth(): MiniappAuthState | null {
359
+ return this.authState ? {...this.authState} : null
360
+ }
361
+
362
+ /**
363
+ * @internal — wait for a scoped miniapp token. Used by session.auth; not part
364
+ * of the public SDK surface because authors should never manage wire events.
365
+ */
366
+ _waitForAuth(minTtlMs: number, timeoutMs = 10_000): Promise<MiniappAuthState> {
367
+ const current = this.authState
368
+ if (current && this.authHasTtl(current, minTtlMs)) {
369
+ return Promise.resolve({...current})
370
+ }
371
+
372
+ void this.requestAuthRefresh(minTtlMs)
373
+
374
+ return new Promise((resolve, reject) => {
375
+ const waiter = {
376
+ minTtlMs,
377
+ resolve,
378
+ reject,
379
+ timer: setTimeout(() => {
380
+ this.authWaiters.delete(waiter)
381
+ reject(new NotConnectedError("Miniapp auth token is not available"))
382
+ }, timeoutMs),
383
+ }
384
+ this.authWaiters.add(waiter)
385
+ })
386
+ }
387
+
388
+ /**
389
+ * @internal — subscribe to a raw stream type. Domain modules call this; it
390
+ * delegates to the EventManager registry. Underscore prefix signals "not
391
+ * part of the public SDK surface — use session.mic.onAudioChunk /
392
+ * session.transcription.on(...)
393
+ * etc. instead."
394
+ */
395
+ _subscribe(streamType: string, handler: (data: unknown) => void): UnsubscribeFn {
396
+ return this.events.subscribe(streamType, handler)
397
+ }
398
+
399
+ // -------------------------------------------------------------------------
400
+ // Connection lifecycle
401
+ // -------------------------------------------------------------------------
402
+
403
+ /**
404
+ * Connect to LocalMiniappRuntime. Idempotent — calling multiple times
405
+ * returns the same Promise.
406
+ */
407
+ connect(): Promise<void> {
408
+ if (this.disposed) {
409
+ return Promise.reject(new NotConnectedError("MiniappSession was disposed"))
410
+ }
411
+ if (this.connectPromise) return this.connectPromise
412
+
413
+ // Register the readiness listener BEFORE any awaits so that a synchronous
414
+ // CONNECT_ACK delivery in tests (or the phone) can't race the subscription.
415
+ const readyPromise = new Promise<void>((resolve, reject) => {
416
+ const timer = setTimeout(() => {
417
+ const err = new Error("MiniappSession: CONNECT_ACK timeout")
418
+ this.failAllPending({code: MiniappErrorCode.NOT_CONNECTED, message: err.message})
419
+ this.emitter.emit("error", err)
420
+ reject(err)
421
+ }, this.connectTimeoutMs)
422
+
423
+ this.emitter.once("ready", () => {
424
+ clearTimeout(timer)
425
+ resolve()
426
+ })
427
+ this.emitter.once("error", (err) => {
428
+ clearTimeout(timer)
429
+ reject(err)
430
+ })
431
+ })
432
+
433
+ this.connectPromise = (async () => {
434
+ this.transport.onMessage((raw) => this.handleIncoming(raw))
435
+ this.transport.onDisconnect((reason) => this.handleTransportDisconnect(reason))
436
+ await this.transport.open()
437
+
438
+ const requestId = makeRequestId()
439
+ const connectPayload = {
440
+ type: MiniappRequestType.CONNECT,
441
+ packageName: this.packageName,
442
+ }
443
+ this.transport.send(serializeEnvelope({payload: connectPayload, requestId}))
444
+
445
+ await readyPromise
446
+ })()
447
+
448
+ return this.connectPromise
449
+ }
450
+
451
+ /** Resolves when `ready` becomes true, or rejects if connect failed. */
452
+ waitForReady(): Promise<void> {
453
+ if (this.ready) return Promise.resolve()
454
+ return this.connect()
455
+ }
456
+
457
+ isConnected(): boolean {
458
+ return this.ready && this.transport.isOpen()
459
+ }
460
+
461
+ disconnect(): void {
462
+ if (this.disposed) return
463
+ this.disposed = true
464
+ // Give listeners one synchronous chance to flush final messages
465
+ // (e.g. display.render([])) before we tear down the transport.
466
+ try {
467
+ this.emitter.emit("beforeDisconnect", "disconnect called")
468
+ } catch (err) {
469
+ // A throwing handler must not block teardown.
470
+ console.warn("[MiniappSession] beforeDisconnect handler threw:", err)
471
+ }
472
+ this.failAllPending({code: MiniappErrorCode.REQUEST_ABORTED, message: "Session disconnected"})
473
+ try {
474
+ this.transport.close()
475
+ } catch {
476
+ // ignore
477
+ }
478
+ this.ready = false
479
+ this.emitter.emit("disconnect", "disconnect called")
480
+ }
481
+
482
+ // -------------------------------------------------------------------------
483
+ // Outbound traffic — modules call these
484
+ // -------------------------------------------------------------------------
485
+
486
+ /** Send a fire-and-forget request that does not need a response. */
487
+ sendOneShot(payload: object): void {
488
+ const envelope: MiniappEnvelope = {payload}
489
+ this.enqueueOrSend(serializeEnvelope(envelope))
490
+ }
491
+
492
+ /**
493
+ * Send a request and get a Promise that resolves with the REQUEST_RESULT payload.
494
+ * Rejects with a MiniappRequestError if the phone returns an error result.
495
+ *
496
+ * `opts.timeoutMs` overrides the default request timeout. Pass `0` to disable
497
+ * it entirely for inherently long-running requests whose duration is unbounded
498
+ * (e.g. audio playback that resolves only when the clip finishes) — those still
499
+ * settle via REQUEST_RESULT or `failAllPending` on disconnect, so they can't
500
+ * leak. Most requests should keep the default ceiling.
501
+ */
502
+ sendRequest<TResult = unknown>(payload: object, opts?: {timeoutMs?: number}): Promise<TResult> {
503
+ if (this.disposed) {
504
+ return Promise.reject(new NotConnectedError())
505
+ }
506
+ const requestId = makeRequestId()
507
+ const envelope: MiniappEnvelope = {payload, requestId}
508
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
509
+ return new Promise<TResult>((resolve, reject) => {
510
+ // Reject (and drop) the request if the host never sends a REQUEST_RESULT,
511
+ // so the promise can't hang forever. The REQUEST_RESULT / failAllPending
512
+ // paths clear this timer before settling. A non-positive timeout opts out
513
+ // (long-running requests rely on the host result / disconnect to settle).
514
+ const timer =
515
+ timeoutMs > 0
516
+ ? setTimeout(() => {
517
+ const pending = this.pendingRequests.get(requestId)
518
+ if (!pending) return
519
+ this.pendingRequests.delete(requestId)
520
+ pending.reject({
521
+ code: MiniappErrorCode.ACTION_TIMEOUT,
522
+ message: "Request timed out waiting for a response from the host",
523
+ })
524
+ }, timeoutMs)
525
+ : undefined
526
+ this.pendingRequests.set(requestId, {
527
+ requestId,
528
+ resolve: resolve as (v: unknown) => void,
529
+ reject,
530
+ timer,
531
+ })
532
+ this.enqueueOrSend(serializeEnvelope(envelope))
533
+ })
534
+ }
535
+
536
+ // -------------------------------------------------------------------------
537
+ // Event emitter — external API
538
+ // -------------------------------------------------------------------------
539
+
540
+ on<K extends keyof SessionEmitterEvents>(event: K, handler: SessionEmitterEvents[K]): () => void {
541
+ this.emitter.on(event, handler as (...args: unknown[]) => void)
542
+ return () => this.emitter.off(event, handler as (...args: unknown[]) => void)
543
+ }
544
+
545
+ off<K extends keyof SessionEmitterEvents>(event: K, handler: SessionEmitterEvents[K]): void {
546
+ this.emitter.off(event, handler as (...args: unknown[]) => void)
547
+ }
548
+
549
+ /**
550
+ * Last-chance hook before the transport closes. Fires either when the
551
+ * phone notifies the session of an imminent disconnect (~50ms grace
552
+ * window before the socket is torn down) or when this session's
553
+ * `disconnect()` is called locally. Use it to flush final cleanup
554
+ * messages — e.g. `display.render([])` — synchronously. Async work
555
+ * started here will not complete before the socket closes.
556
+ */
557
+ onBeforeDisconnect(handler: (reason: string) => void): () => void {
558
+ return this.on("beforeDisconnect", handler)
559
+ }
560
+
561
+ onVisibilityChange(handler: (v: MiniappVisibility) => void): () => void {
562
+ return this.on("visibility", handler)
563
+ }
564
+
565
+ onCapabilitiesChange(handler: (cap: GlassesCapabilities | null) => void): () => void {
566
+ return this.on("capabilities", handler)
567
+ }
568
+
569
+ onColorSchemeChange(handler: (scheme: MiniappColorScheme) => void): () => void {
570
+ return this.on("colorScheme", handler)
571
+ }
572
+
573
+ // -------------------------------------------------------------------------
574
+ // Internal — transport glue
575
+ // -------------------------------------------------------------------------
576
+
577
+ private enqueueOrSend(raw: string): void {
578
+ if (this.ready) {
579
+ try {
580
+ this.transport.send(raw)
581
+ } catch (err) {
582
+ // If send fails post-ready, treat as transport error.
583
+ this.emitter.emit("error", err as Error)
584
+ }
585
+ return
586
+ }
587
+ this.outboundQueue.push(raw)
588
+ }
589
+
590
+ private flushQueue(): void {
591
+ const queue = this.outboundQueue.splice(0)
592
+ for (const raw of queue) {
593
+ try {
594
+ this.transport.send(raw)
595
+ } catch (err) {
596
+ this.emitter.emit("error", err as Error)
597
+ }
598
+ }
599
+ }
600
+
601
+ private handleIncoming(raw: string): void {
602
+ const envelope = parseEnvelope(raw)
603
+ if (!envelope) return
604
+
605
+ const payload = envelope.payload as {type?: string} & Record<string, unknown>
606
+ const type = payload?.type
607
+
608
+ switch (type) {
609
+ case MiniappResponseType.CONNECT_ACK: {
610
+ const ack = payload as unknown as ConnectAckPayload
611
+ this.userId = ack.userId ?? ""
612
+ if (ack.packageName) this.packageName = ack.packageName
613
+ this.capabilities = ack.capabilities ?? null
614
+ if (ack.visibility) this.visibility = ack.visibility
615
+ if (ack.colorScheme === "light" || ack.colorScheme === "dark") {
616
+ this.colorScheme = ack.colorScheme
617
+ }
618
+ if (ack.auth) {
619
+ this.applyAuth(ack.auth)
620
+ if (!this.userId) this.userId = ack.auth.mentraUserId
621
+ }
622
+ // Populate the manifest-declared permission cache. Older runtimes
623
+ // that don't send `permissions` leave the all-false default in place
624
+ // — `hasPermission` getters will simply return false.
625
+ if (ack.permissions) this.applyPermissions(ack.permissions)
626
+ this.ready = true
627
+ this.flushQueue()
628
+ this.emitter.emit("ready")
629
+ // Don't resolve request correlation here — CONNECT_ACK has no requestId.
630
+ return
631
+ }
632
+
633
+ case MiniappResponseType.AUTH_UPDATE: {
634
+ const next = (payload as unknown as AuthUpdatePayload).auth
635
+ if (next) {
636
+ this.applyAuth(next)
637
+ if (!this.userId) this.userId = next.mentraUserId
638
+ }
639
+ return
640
+ }
641
+
642
+ case MiniappResponseType.PERMISSIONS_UPDATE: {
643
+ const next = payload.permissions as PermissionRecord | undefined
644
+ if (next) this.applyPermissions(next)
645
+ return
646
+ }
647
+
648
+ case MiniappResponseType.SPEAKER_STATE: {
649
+ const state = payload.state as "idle" | "loading" | "playing" | "stopped" | "error" | undefined
650
+ if (!state) return
651
+ const event = {
652
+ state,
653
+ errorCode: payload.errorCode as string | undefined,
654
+ errorMessage: payload.errorMessage as string | undefined,
655
+ durationMs: payload.durationMs as number | undefined,
656
+ }
657
+ this.speaker._applyState(event)
658
+ this.emitter.emit("speakerState", event)
659
+ return
660
+ }
661
+
662
+ case MiniappRequestType.PING: {
663
+ // Phone → miniapp keepalive ping. Auto-reply with PONG.
664
+ const pong: object = {type: MiniappResponseType.PONG}
665
+ const env: MiniappEnvelope = {
666
+ payload: pong,
667
+ ...(envelope.requestId ? {requestId: envelope.requestId} : {}),
668
+ }
669
+ try {
670
+ this.transport.send(serializeEnvelope(env))
671
+ } catch {
672
+ // Ignore; next ping will fail too and runtime will unregister.
673
+ }
674
+ return
675
+ }
676
+
677
+ case MiniappResponseType.EVENT: {
678
+ const streamType = payload.streamType as string | undefined
679
+ if (!streamType) return
680
+ this.events._forwardEvent(streamType, payload.data)
681
+ return
682
+ }
683
+
684
+ case MiniappResponseType.ACTION_CALL: {
685
+ // Another miniapp invoked one of our declared actions. Route to the
686
+ // registered handler (or buffer briefly for one to register). The SDK
687
+ // replies with an ACTION_RESULT request keyed by callId.
688
+ const callId = payload.callId as string | undefined
689
+ const actionId = payload.actionId as string | undefined
690
+ if (!callId || !actionId) return
691
+ const params = (payload.params as Record<string, unknown> | undefined) ?? {}
692
+ const callerPackageName = (payload.callerPackageName as string | undefined) ?? ""
693
+ this.actions._deliver(callId, actionId, params, {callerPackageName})
694
+ return
695
+ }
696
+
697
+ case MiniappResponseType.CAPABILITIES_UPDATE: {
698
+ const cap = (payload.capabilities as GlassesCapabilities | null) ?? null
699
+ this.capabilities = cap
700
+ this.emitter.emit("capabilities", cap)
701
+ return
702
+ }
703
+
704
+ case MiniappResponseType.VISIBILITY_CHANGE: {
705
+ const next = payload.visibility as MiniappVisibility | undefined
706
+ if (next === "foreground" || next === "background") {
707
+ this.visibility = next
708
+ this.emitter.emit("visibility", next)
709
+ }
710
+ return
711
+ }
712
+
713
+ case MiniappResponseType.COLOR_SCHEME_CHANGE: {
714
+ const next = payload.colorScheme as MiniappColorScheme | undefined
715
+ if (next === "light" || next === "dark") {
716
+ this.colorScheme = next
717
+ this.emitter.emit("colorScheme", next)
718
+ }
719
+ return
720
+ }
721
+
722
+ case MiniappResponseType.REQUEST_RESULT: {
723
+ const requestId = envelope.requestId
724
+ if (!requestId) return
725
+ const pending = this.pendingRequests.get(requestId)
726
+ if (!pending) return
727
+ this.pendingRequests.delete(requestId)
728
+ if (pending.timer) clearTimeout(pending.timer)
729
+ if (payload.ok === false) {
730
+ const err = (payload.error as MiniappRequestError | undefined) ?? {
731
+ code: MiniappErrorCode.INTERNAL,
732
+ message: "Unknown error",
733
+ }
734
+ pending.reject(err)
735
+ } else {
736
+ pending.resolve(payload.data ?? null)
737
+ }
738
+ return
739
+ }
740
+
741
+ case MiniappResponseType.WILL_DISCONNECT: {
742
+ const reason = (payload.reason as string | undefined) ?? "phone unregistering"
743
+ try {
744
+ this.emitter.emit("beforeDisconnect", reason)
745
+ } catch (err) {
746
+ console.warn("[MiniappSession] beforeDisconnect handler threw:", err)
747
+ }
748
+ return
749
+ }
750
+
751
+ case MiniappResponseType.ERROR: {
752
+ const err = new Error((payload.message as string | undefined) ?? "MiniappSession error")
753
+ this.emitter.emit("error", err)
754
+ return
755
+ }
756
+
757
+ default:
758
+ // Unknown type — drop silently. Forward-compat.
759
+ return
760
+ }
761
+ }
762
+
763
+ private handleTransportDisconnect(reason: string): void {
764
+ this.ready = false
765
+ this.failAllPending({code: MiniappErrorCode.NOT_CONNECTED, message: `Transport disconnected: ${reason}`})
766
+ this.emitter.emit("disconnect", reason)
767
+ }
768
+
769
+ private failAllPending(error: MiniappRequestError): void {
770
+ for (const pending of this.pendingRequests.values()) {
771
+ if (pending.timer) clearTimeout(pending.timer)
772
+ pending.reject(error)
773
+ }
774
+ this.pendingRequests.clear()
775
+ // Auth waiters live outside pendingRequests — they're resolved by an
776
+ // AUTH_UPDATE push, not a correlated REQUEST_RESULT — so a transport drop,
777
+ // dispose, or CONNECT_ACK timeout must reject them here too. Otherwise an
778
+ // in-flight session.auth.getToken()/fetch() hangs until its 10s waiter
779
+ // timeout instead of failing fast with the disconnect error.
780
+ for (const waiter of Array.from(this.authWaiters)) {
781
+ clearTimeout(waiter.timer)
782
+ this.authWaiters.delete(waiter)
783
+ waiter.reject(new NotConnectedError(error.message))
784
+ }
785
+ }
786
+
787
+ private authHasTtl(auth: MiniappAuthState, minTtlMs: number): boolean {
788
+ return auth.expiresAt - Date.now() > minTtlMs
789
+ }
790
+
791
+ private requestAuthRefresh(minTtlMs: number): Promise<MiniappAuthState | null> {
792
+ if (this.authRefreshPromise) return this.authRefreshPromise
793
+ if (!this.ready || !this.transport.isOpen()) return Promise.resolve(null)
794
+
795
+ this.authRefreshPromise = this.sendRequest<AuthRefreshResult>({
796
+ type: MiniappRequestType.AUTH_REFRESH,
797
+ minTtlMs,
798
+ })
799
+ .then((result) => {
800
+ const auth = result?.auth
801
+ if (auth) this.applyAuth(auth)
802
+ return auth ?? null
803
+ })
804
+ .catch((err) => {
805
+ console.warn("[MiniappSession] auth refresh failed:", err)
806
+ return null
807
+ })
808
+ .finally(() => {
809
+ this.authRefreshPromise = null
810
+ })
811
+
812
+ return this.authRefreshPromise
813
+ }
814
+
815
+ private applyAuth(next: MiniappAuthState): void {
816
+ this.authState = {...next}
817
+ for (const waiter of Array.from(this.authWaiters)) {
818
+ if (!this.authHasTtl(next, waiter.minTtlMs)) continue
819
+ clearTimeout(waiter.timer)
820
+ this.authWaiters.delete(waiter)
821
+ waiter.resolve({...next})
822
+ }
823
+ this.emitter.emit("auth", {...next})
824
+ }
825
+
826
+ /**
827
+ * Update the cached permission record. Idempotent: emits "permissions"
828
+ * only when the record actually changed. Sanitizes incoming objects to
829
+ * the v3 PermissionType union.
830
+ */
831
+ private applyPermissions(next: Partial<PermissionRecord>): void {
832
+ let changed = false
833
+ const updated: PermissionRecord = {...this._permissions}
834
+ for (const k of ALL_PERMISSION_TYPES) {
835
+ const v = next[k] === true
836
+ if (updated[k] !== v) {
837
+ updated[k] = v
838
+ changed = true
839
+ }
840
+ }
841
+ if (changed) {
842
+ this._permissions = updated
843
+ this.emitter.emit("permissions", {...updated})
844
+ }
845
+ }
846
+ }
847
+
848
+ /**
849
+ * Map a manifest UPPER_CASE permission name to the lowercase canonical key
850
+ * used by `session.permissions`. Returns null for unknown manifest keys.
851
+ *
852
+ * BACKGROUND_LOCATION + POST_NOTIFICATIONS map onto the same canonical keys
853
+ * as their non-suffixed counterparts (location / notifications) since
854
+ * `has()` is "do I have *any* form of this permission declared".
855
+ */
856
+ function manifestKeyToCanonical(manifestKey: string): PermissionType | null {
857
+ switch (manifestKey.toUpperCase()) {
858
+ case "MICROPHONE":
859
+ return "microphone"
860
+ case "CAMERA":
861
+ return "camera"
862
+ case "LOCATION":
863
+ case "BACKGROUND_LOCATION":
864
+ return "location"
865
+ case "READ_NOTIFICATIONS":
866
+ case "POST_NOTIFICATIONS":
867
+ return "notifications"
868
+ case "CALENDAR":
869
+ return "calendar"
870
+ default:
871
+ return null
872
+ }
873
+ }
874
+
875
+ /** @internal — for the permissions module's onUpdate plumbing. */
876
+ export function _allPermissionTypes(): readonly PermissionType[] {
877
+ return ALL_PERMISSION_TYPES
878
+ }