@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
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @fileoverview ActionsModule — `session.actions`.
3
+ *
4
+ * The MCP-shaped capability layer, both directions:
5
+ * - `invoke(pkg, actionId, params)` — call another miniapp's declared action.
6
+ * SYSTEM-only; headless-wakes the target if it's stopped.
7
+ * - `handle(actionId, fn)` — expose one of your own actions. Open to all
8
+ * miniapps. Mirrors `session.ui.handle` (one handler per id, throws on
9
+ * double-register, errors propagate to the caller).
10
+ *
11
+ * `invoke` ↔ `handle` are the two ends of one wire (keyed by `actionId`), which
12
+ * is why they share this namespace rather than being split across
13
+ * `session.miniapps`.
14
+ */
15
+
16
+ import {MiniappErrorCode, MiniappRequestType} from "../protocol"
17
+ import {MiniappSession} from "../session"
18
+
19
+ /** Context handed to an action handler. `callerPackageName` is host-stamped. */
20
+ export interface ActionContext {
21
+ /** The package that invoked this action (trustworthy — set by the host). */
22
+ callerPackageName: string
23
+ }
24
+
25
+ export type ActionHandler = (params: Record<string, unknown>, ctx: ActionContext) => unknown | Promise<unknown>
26
+
27
+ export interface InvokeOptions {
28
+ /** Overall timeout in ms (default 30s, max 120s — host-enforced). */
29
+ timeoutMs?: number
30
+ }
31
+
32
+ interface BufferedCall {
33
+ callId: string
34
+ params: Record<string, unknown>
35
+ ctx: ActionContext
36
+ timer: ReturnType<typeof setTimeout>
37
+ }
38
+
39
+ /**
40
+ * How long the SDK holds an inbound ACTION_CALL waiting for its handler to
41
+ * register (a just-woken miniapp registers handlers a beat after CONNECT).
42
+ * If no handler appears in this window the call is failed with
43
+ * NO_ACTION_HANDLER. Mirrors the UI bus's pre-handler buffering.
44
+ */
45
+ const HANDLER_WAIT_MS = 5_000
46
+
47
+ export class ActionsModule {
48
+ private readonly handlers = new Map<string, ActionHandler>()
49
+ /** Inbound calls buffered until their handler registers, keyed by actionId. */
50
+ private readonly buffered = new Map<string, BufferedCall[]>()
51
+
52
+ constructor(private readonly session: MiniappSession) {}
53
+
54
+ /**
55
+ * Invoke a declared action on another miniapp. Resolves with the handler's
56
+ * return value, rejects with a MiniappRequestError (NOT_PERMITTED,
57
+ * APP_NOT_FOUND, ACTION_NOT_FOUND, WAKE_FAILED, NO_ACTION_HANDLER,
58
+ * ACTION_TIMEOUT, PAYLOAD_TOO_LARGE, or the handler's own error). SYSTEM-only.
59
+ */
60
+ invoke<TResult = unknown>(
61
+ packageName: string,
62
+ actionId: string,
63
+ params?: Record<string, unknown>,
64
+ opts?: InvokeOptions,
65
+ ): Promise<TResult> {
66
+ return this.session.sendRequest<TResult>({
67
+ type: MiniappRequestType.ACTION_INVOKE,
68
+ targetPackageName: packageName,
69
+ actionId,
70
+ params: params ?? {},
71
+ ...(opts?.timeoutMs != null ? {timeoutMs: opts.timeoutMs} : {}),
72
+ })
73
+ }
74
+
75
+ /**
76
+ * Register a handler for one of this miniapp's declared actions. One handler
77
+ * per actionId — throws synchronously on double-register. The returned
78
+ * function unregisters. Declare the action in `miniapp.json` for it to be
79
+ * invokable; an undeclared id still registers but the host never routes to it.
80
+ */
81
+ handle(actionId: string, handler: ActionHandler): () => void {
82
+ if (this.handlers.has(actionId)) {
83
+ throw new Error(`session.actions.handle: a handler is already registered for "${actionId}"`)
84
+ }
85
+ this.handlers.set(actionId, handler)
86
+
87
+ // Flush any calls that arrived before this handler registered.
88
+ const waiting = this.buffered.get(actionId)
89
+ if (waiting) {
90
+ this.buffered.delete(actionId)
91
+ for (const call of waiting) {
92
+ clearTimeout(call.timer)
93
+ void this.dispatch(call.callId, actionId, call.params, call.ctx)
94
+ }
95
+ }
96
+
97
+ return () => {
98
+ if (this.handlers.get(actionId) === handler) this.handlers.delete(actionId)
99
+ }
100
+ }
101
+
102
+ /**
103
+ * @internal — called by MiniappSession.handleIncoming on an ACTION_CALL.
104
+ * Routes to the registered handler, or buffers briefly for one to appear.
105
+ */
106
+ _deliver(callId: string, actionId: string, params: Record<string, unknown>, ctx: ActionContext): void {
107
+ if (this.handlers.has(actionId)) {
108
+ void this.dispatch(callId, actionId, params, ctx)
109
+ return
110
+ }
111
+ // No handler yet — buffer, and fail with NO_ACTION_HANDLER if none registers.
112
+ const timer = setTimeout(() => {
113
+ const arr = this.buffered.get(actionId)
114
+ if (arr) {
115
+ const idx = arr.findIndex((c) => c.callId === callId)
116
+ if (idx >= 0) arr.splice(idx, 1)
117
+ if (arr.length === 0) this.buffered.delete(actionId)
118
+ }
119
+ this.sendResult(callId, false, undefined, {
120
+ code: MiniappErrorCode.NO_ACTION_HANDLER,
121
+ message: `No handler registered for action "${actionId}"`,
122
+ })
123
+ }, HANDLER_WAIT_MS)
124
+ const arr = this.buffered.get(actionId) ?? []
125
+ arr.push({callId, params, ctx, timer})
126
+ this.buffered.set(actionId, arr)
127
+ }
128
+
129
+ private async dispatch(
130
+ callId: string,
131
+ actionId: string,
132
+ params: Record<string, unknown>,
133
+ ctx: ActionContext,
134
+ ): Promise<void> {
135
+ const handler = this.handlers.get(actionId)
136
+ if (!handler) return
137
+ try {
138
+ const result = await handler(params, ctx)
139
+ this.sendResult(callId, true, result ?? null)
140
+ } catch (e) {
141
+ this.sendResult(callId, false, undefined, {
142
+ code: MiniappErrorCode.INTERNAL,
143
+ message: (e as Error)?.message ?? "action handler threw",
144
+ })
145
+ }
146
+ }
147
+
148
+ private sendResult(callId: string, ok: boolean, result?: unknown, error?: {code: string; message: string}): void {
149
+ this.session.sendOneShot({
150
+ type: MiniappRequestType.ACTION_RESULT,
151
+ callId,
152
+ ok,
153
+ ...(ok ? {result} : {error}),
154
+ })
155
+ }
156
+ }
@@ -0,0 +1,68 @@
1
+ import type {MiniappSession, MiniappAuthState} from "../session"
2
+
3
+ export interface AuthFetchOptions extends RequestInit {
4
+ /**
5
+ * Minimum remaining token lifetime before using the current token. Defaults to
6
+ * 30 seconds. If the token is absent or too close to expiry, the call waits
7
+ * for the host's next auth update.
8
+ */
9
+ minTtlMs?: number
10
+ }
11
+
12
+ const DEFAULT_MIN_TTL_MS = 30_000
13
+
14
+ export class AuthModule {
15
+ constructor(private readonly session: MiniappSession) {}
16
+
17
+ get current(): MiniappAuthState | null {
18
+ return this.session._getAuth()
19
+ }
20
+
21
+ get mentraUserId(): string | null {
22
+ return this.current?.mentraUserId ?? null
23
+ }
24
+
25
+ get oemId(): string | null {
26
+ return this.current?.oemId ?? null
27
+ }
28
+
29
+ async getToken(options: {minTtlMs?: number} = {}): Promise<string> {
30
+ const auth = await this.session._waitForAuth(options.minTtlMs ?? DEFAULT_MIN_TTL_MS)
31
+ return auth.token
32
+ }
33
+
34
+ async getAuthHeader(options: {minTtlMs?: number} = {}): Promise<string> {
35
+ return `Bearer ${await this.getToken(options)}`
36
+ }
37
+
38
+ async fetch(input: RequestInfo | URL, init: AuthFetchOptions = {}): Promise<Response> {
39
+ const {minTtlMs, ...requestInit} = init
40
+ const token = await this.getToken({minTtlMs})
41
+ const headers = mergeHeaders(requestInit.headers, {Authorization: `Bearer ${token}`})
42
+ return fetch(input, {...requestInit, headers})
43
+ }
44
+
45
+ onUpdate(handler: (auth: MiniappAuthState) => void): () => void {
46
+ return this.session.on("auth", handler)
47
+ }
48
+ }
49
+
50
+ function mergeHeaders(base: HeadersInit | undefined, next: Record<string, string>): Record<string, string> {
51
+ const headers: Record<string, string> = {}
52
+
53
+ if (Array.isArray(base)) {
54
+ for (const [key, value] of base) {
55
+ headers[key] = value
56
+ }
57
+ } else if (typeof Headers !== "undefined" && base instanceof Headers) {
58
+ base.forEach((value, key) => {
59
+ headers[key] = value
60
+ })
61
+ } else if (base && typeof base === "object") {
62
+ for (const [key, value] of Object.entries(base)) {
63
+ if (typeof value === "string") headers[key] = value
64
+ }
65
+ }
66
+
67
+ return {...headers, ...next}
68
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @fileoverview Dependency-free base64 codec for the miniapp background runtime.
3
+ *
4
+ * The background bundle runs in a per-miniapp JS engine (JavaScriptCore on iOS,
5
+ * QuickJS on Android) where `Buffer`, `btoa`/`atob`, and Node APIs are NOT
6
+ * guaranteed to exist. `session.blob` round-trips binary as base64 strings over
7
+ * the JSON bridge, so it needs an encoder/decoder that works with nothing but
8
+ * the language. This is that — standard RFC 4648 base64, no padding tricks.
9
+ */
10
+
11
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
12
+
13
+ /** Reverse lookup table: char code → 6-bit value (or -1). Built once. */
14
+ const LOOKUP: Int16Array = (() => {
15
+ const t = new Int16Array(256).fill(-1)
16
+ for (let i = 0; i < ALPHABET.length; i++) t[ALPHABET.charCodeAt(i)] = i
17
+ t["=".charCodeAt(0)] = 0
18
+ return t
19
+ })()
20
+
21
+ /** Coerce a Uint8Array / ArrayBuffer / ArrayBufferView into a Uint8Array view (no copy when possible). */
22
+ export function toUint8Array(input: Uint8Array | ArrayBuffer | ArrayBufferView): Uint8Array {
23
+ if (input instanceof Uint8Array) return input
24
+ if (input instanceof ArrayBuffer) return new Uint8Array(input)
25
+ return new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
26
+ }
27
+
28
+ /** Encode bytes to a base64 string. */
29
+ export function bytesToBase64(input: Uint8Array | ArrayBuffer): string {
30
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input)
31
+ const len = bytes.length
32
+ let out = ""
33
+ let i = 0
34
+ for (; i + 2 < len; i += 3) {
35
+ const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]
36
+ out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + ALPHABET[n & 63]
37
+ }
38
+ const rem = len - i
39
+ if (rem === 1) {
40
+ const n = bytes[i] << 16
41
+ out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + "=="
42
+ } else if (rem === 2) {
43
+ const n = (bytes[i] << 16) | (bytes[i + 1] << 8)
44
+ out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + "="
45
+ }
46
+ return out
47
+ }
48
+
49
+ /** Decode a base64 string to bytes. Ignores whitespace; tolerant of missing padding. */
50
+ export function base64ToBytes(b64: string): Uint8Array {
51
+ // Count valid (non-whitespace) chars and trailing '=' to size the output exactly.
52
+ let validChars = 0
53
+ let pad = 0
54
+ for (let i = 0; i < b64.length; i++) {
55
+ const c = b64.charCodeAt(i)
56
+ if (c === 0x3d) {
57
+ pad++
58
+ validChars++
59
+ } else if (LOOKUP[c] !== -1) {
60
+ validChars++
61
+ }
62
+ }
63
+ const fullGroups = Math.floor(validChars / 4)
64
+ const remChars = validChars - fullGroups * 4
65
+ let outLen = fullGroups * 3 - (pad > 0 && remChars === 0 ? pad : 0)
66
+ if (remChars === 2) outLen += 1
67
+ else if (remChars === 3) outLen += 2
68
+ if (outLen < 0) outLen = 0
69
+
70
+ const out = new Uint8Array(outLen)
71
+ let acc = 0
72
+ let bits = 0
73
+ let o = 0
74
+ for (let i = 0; i < b64.length && o < outLen; i++) {
75
+ const c = b64.charCodeAt(i)
76
+ if (c === 0x3d) break // '=' — end of data
77
+ const v = LOOKUP[c]
78
+ if (v === -1) continue // skip whitespace / stray chars
79
+ acc = (acc << 6) | v
80
+ bits += 6
81
+ if (bits >= 8) {
82
+ bits -= 8
83
+ out[o++] = (acc >> bits) & 0xff
84
+ }
85
+ }
86
+ return out
87
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * @fileoverview BlobModule — phone-local persistent BINARY storage, keyed and
3
+ * scoped to (userId, packageName). The binary sibling of `session.storage`:
4
+ * where storage holds small strings, blob holds arbitrary bytes (PDFs, EPUBs,
5
+ * audio, video, model files, caches) as files on the phone.
6
+ *
7
+ * It deliberately mirrors `session.storage`'s shape — `set` / `get` / `delete` /
8
+ * `keys` / `has` / `clear` — so it's instantly familiar, plus the things files
9
+ * (and not strings) need:
10
+ * - `setFromUrl(key, url)` — the host downloads a URL straight to disk
11
+ * (like the web Cache API's `cache.add`, iOS `URLSession` download task, or
12
+ * Android `DownloadManager`). Bytes never cross the bridge.
13
+ * - `importFile()` — opens the OS file picker and stores the chosen file.
14
+ * Bytes never cross the bridge.
15
+ * - `share(key)` — hands the file to the OS share sheet.
16
+ * - `createWriteStream` / `createReadStream` — for streaming huge payloads
17
+ * (named like Node's `fs.createWriteStream`).
18
+ *
19
+ * BACKGROUND-ONLY. Binary doesn't belong in the WebView. A blob's `uri` is a
20
+ * `file://` path in the app's private storage — fine for `session.speaker.play`,
21
+ * `blob.share`, or another host capability, but a WebView generally can't load
22
+ * it directly (route rendering through a host viewer for now).
23
+ *
24
+ * Transfer model: the bridge moves JSON strings through a per-miniapp JS engine
25
+ * (JSC/QuickJS) with a watchdog, so in-JS writes/reads are CHUNKED (≤ ~1 MB raw
26
+ * per call). Prefer `setFromUrl` / `importFile` when you can — they keep the
27
+ * bytes entirely host-side.
28
+ */
29
+
30
+ import {MiniappRequestType} from "../protocol"
31
+ import {MiniappSession} from "../session"
32
+ import {base64ToBytes, bytesToBase64, toUint8Array} from "./base64"
33
+
34
+ /** Metadata for one stored blob. `uri` is a `file://` path. */
35
+ export interface BlobMeta {
36
+ /** The key it's stored under (caller-chosen). */
37
+ key: string
38
+ /** Optional display/file name (e.g. the original filename for an import). */
39
+ name?: string
40
+ /** MIME type, e.g. "application/pdf". */
41
+ mimeType: string
42
+ /** Size on disk in bytes. */
43
+ bytes: number
44
+ /** Epoch ms first written. */
45
+ createdAt: number
46
+ /** Epoch ms last written. */
47
+ updatedAt: number
48
+ /** Content md5 (lowercase hex), computed host-side (skipped for very large blobs). */
49
+ md5?: string
50
+ /** `file://` URI. Feed to `session.speaker.play`, `blob.share`, etc. */
51
+ uri: string
52
+ /** App-defined metadata persisted with the blob. */
53
+ meta?: Record<string, string | number | boolean>
54
+ }
55
+
56
+ export interface BlobSetOptions {
57
+ /** Default "application/octet-stream". */
58
+ mimeType?: string
59
+ name?: string
60
+ meta?: Record<string, string | number | boolean>
61
+ }
62
+
63
+ export interface BlobSetFromUrlOptions extends BlobSetOptions {
64
+ /** Request headers (e.g. an Authorization token) for the download. */
65
+ headers?: Record<string, string>
66
+ }
67
+
68
+ export interface BlobImportOptions {
69
+ /** Key to store under. Default: a generated key. */
70
+ key?: string
71
+ /** Restrict the picker to a MIME type, e.g. "application/pdf". */
72
+ mimeType?: string
73
+ meta?: Record<string, string | number | boolean>
74
+ }
75
+
76
+ /** Raw bytes per chunked write. ~1 MB raw → ~1.34 MB base64, under the watchdog. */
77
+ export const BLOB_WRITE_CHUNK_BYTES = 1024 * 1024
78
+ const BLOB_WRITE_CHUNK_B64 = Math.floor(BLOB_WRITE_CHUNK_BYTES / 3) * 4
79
+ /** Max bytes `bytes()` will buffer before throwing — read large blobs as a stream. */
80
+ export const BLOB_READ_ALL_MAX_BYTES = 32 * 1024 * 1024
81
+
82
+ /**
83
+ * Streaming writer (`fs.createWriteStream`-like). Call `write`/`writeBase64` as
84
+ * many times as you like (each is auto-split into bridge-safe chunks), then
85
+ * `close()` to publish, or `abort()` to discard the partial blob.
86
+ */
87
+ export class BlobWriter {
88
+ private settled = false
89
+
90
+ constructor(
91
+ private readonly session: MiniappSession,
92
+ readonly key: string,
93
+ ) {}
94
+
95
+ /** Append raw bytes. Auto-chunked. */
96
+ async write(chunk: Uint8Array | ArrayBuffer): Promise<void> {
97
+ this.assertOpen()
98
+ const bytes = toUint8Array(chunk)
99
+ for (let off = 0; off < bytes.length; off += BLOB_WRITE_CHUNK_BYTES) {
100
+ await this.send(bytesToBase64(bytes.subarray(off, off + BLOB_WRITE_CHUNK_BYTES)))
101
+ }
102
+ }
103
+
104
+ /** Append already-base64-encoded bytes (e.g. straight from `mic.onAudioChunk`). */
105
+ async writeBase64(b64: string): Promise<void> {
106
+ this.assertOpen()
107
+ // Slice on 4-char boundaries so each chunk is whole base64 groups.
108
+ for (let off = 0; off < b64.length; off += BLOB_WRITE_CHUNK_B64) {
109
+ await this.send(b64.slice(off, off + BLOB_WRITE_CHUNK_B64))
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Overwrite bytes at a fixed offset within the not-yet-closed blob (a seek
115
+ * write). Must stay within already-written bytes; does not grow the blob.
116
+ * Advanced — used e.g. to patch a container header on finalize.
117
+ */
118
+ async writeAt(offset: number, chunk: Uint8Array | ArrayBuffer): Promise<void> {
119
+ this.assertOpen()
120
+ await this.session.sendRequest<{bytesWritten: number}>({
121
+ type: MiniappRequestType.BLOB_WRITE,
122
+ key: this.key,
123
+ offset,
124
+ base64: bytesToBase64(toUint8Array(chunk)),
125
+ })
126
+ }
127
+
128
+ /** Finalize and return the blob's metadata. Optional `meta` merges into the record. */
129
+ async close(meta?: Record<string, string | number | boolean>): Promise<BlobMeta> {
130
+ this.assertOpen()
131
+ this.settled = true
132
+ return this.session.sendRequest<BlobMeta>({type: MiniappRequestType.BLOB_COMMIT, key: this.key, meta})
133
+ }
134
+
135
+ /** Discard the partial blob. Idempotent. */
136
+ async abort(): Promise<void> {
137
+ if (this.settled) return
138
+ this.settled = true
139
+ await this.session.sendRequest<void>({type: MiniappRequestType.BLOB_ABORT, key: this.key})
140
+ }
141
+
142
+ private assertOpen(): void {
143
+ if (this.settled) throw new Error("BlobWriter is already closed/aborted")
144
+ }
145
+
146
+ private send(base64: string): Promise<{bytesWritten: number}> {
147
+ return this.session.sendRequest<{bytesWritten: number}>({
148
+ type: MiniappRequestType.BLOB_WRITE,
149
+ key: this.key,
150
+ base64,
151
+ })
152
+ }
153
+ }
154
+
155
+ /** Streaming reader (`fs.createReadStream`-like). */
156
+ export class BlobReader {
157
+ private closed = false
158
+
159
+ constructor(
160
+ private readonly session: MiniappSession,
161
+ readonly handle: string,
162
+ readonly meta: BlobMeta,
163
+ ) {}
164
+
165
+ /** Read up to `maxBytes` (default one chunk). `done` is true at end of file. */
166
+ async read(maxBytes = BLOB_WRITE_CHUNK_BYTES): Promise<{bytes: Uint8Array; done: boolean}> {
167
+ if (this.closed) throw new Error("BlobReader is closed")
168
+ const res = await this.session.sendRequest<{base64: string; done: boolean}>({
169
+ type: MiniappRequestType.BLOB_READ,
170
+ handle: this.handle,
171
+ maxBytes,
172
+ })
173
+ return {bytes: base64ToBytes(res?.base64 ?? ""), done: !!res?.done}
174
+ }
175
+
176
+ async close(): Promise<void> {
177
+ if (this.closed) return
178
+ this.closed = true
179
+ await this.session.sendRequest<void>({type: MiniappRequestType.BLOB_CLOSE_READ, handle: this.handle})
180
+ }
181
+ }
182
+
183
+ export class BlobModule {
184
+ constructor(private readonly session: MiniappSession) {}
185
+
186
+ // ── write ────────────────────────────────────────────────────────────────
187
+
188
+ /** Store bytes under `key`. `data` may be a Uint8Array/ArrayBuffer or a base64 string. */
189
+ async set(key: string, data: Uint8Array | ArrayBuffer | string, opts: BlobSetOptions = {}): Promise<BlobMeta> {
190
+ const writer = await this.createWriteStream(key, opts)
191
+ try {
192
+ if (typeof data === "string") await writer.writeBase64(data)
193
+ else await writer.write(data)
194
+ return await writer.close()
195
+ } catch (err) {
196
+ await writer.abort().catch(() => {})
197
+ throw err
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Download `url` straight into a blob under `key`, host-side. The bytes never
203
+ * cross the bridge (like the web Cache API's `cache.add`). `opts.headers` lets
204
+ * you pass auth. Resolves to the stored blob's metadata.
205
+ */
206
+ setFromUrl(key: string, url: string, opts: BlobSetFromUrlOptions = {}): Promise<BlobMeta> {
207
+ return this.session.sendRequest<BlobMeta>({
208
+ type: MiniappRequestType.BLOB_SET_FROM_URL,
209
+ key,
210
+ url,
211
+ mimeType: opts.mimeType,
212
+ name: opts.name,
213
+ headers: opts.headers,
214
+ meta: opts.meta,
215
+ })
216
+ }
217
+
218
+ /**
219
+ * Open the OS file picker and store the chosen file as a blob, host-side.
220
+ * Resolves to the blob's metadata, or `null` if the user cancelled.
221
+ */
222
+ importFile(opts: BlobImportOptions = {}): Promise<BlobMeta | null> {
223
+ return this.session.sendRequest<BlobMeta | null>({
224
+ type: MiniappRequestType.BLOB_IMPORT,
225
+ key: opts.key,
226
+ mimeType: opts.mimeType,
227
+ meta: opts.meta,
228
+ })
229
+ }
230
+
231
+ /** Open a streaming writer for `key` (for large/streamed payloads). */
232
+ async createWriteStream(key: string, opts: BlobSetOptions = {}): Promise<BlobWriter> {
233
+ const res = await this.session.sendRequest<{key: string}>({
234
+ type: MiniappRequestType.BLOB_CREATE,
235
+ key,
236
+ mimeType: opts.mimeType,
237
+ name: opts.name,
238
+ meta: opts.meta,
239
+ })
240
+ return new BlobWriter(this.session, res.key)
241
+ }
242
+
243
+ // ── read ─────────────────────────────────────────────────────────────────
244
+
245
+ /** Metadata (incl. the `file://` uri) for `key`, or null if absent. */
246
+ get(key: string): Promise<BlobMeta | null> {
247
+ return this.session.sendRequest<BlobMeta | null>({type: MiniappRequestType.BLOB_GET, key})
248
+ }
249
+
250
+ /** Alias of `get` — the file-stat verb. */
251
+ stat(key: string): Promise<BlobMeta | null> {
252
+ return this.get(key)
253
+ }
254
+
255
+ /** True iff a blob is stored under `key`. */
256
+ async has(key: string): Promise<boolean> {
257
+ return (await this.get(key)) !== null
258
+ }
259
+
260
+ /** Every key this miniapp has stored, newest first. */
261
+ async keys(): Promise<string[]> {
262
+ return (await this.list()).map((m) => m.key)
263
+ }
264
+
265
+ /** Every blob this miniapp owns, newest first. */
266
+ async list(): Promise<BlobMeta[]> {
267
+ const res = await this.session.sendRequest<{blobs: BlobMeta[]}>({type: MiniappRequestType.BLOB_LIST})
268
+ return res?.blobs ?? []
269
+ }
270
+
271
+ /** Open a streaming reader. Prefer `get(key).uri` when you just need to play/share it. */
272
+ async createReadStream(key: string): Promise<BlobReader> {
273
+ const res = await this.session.sendRequest<{handle: string; meta: BlobMeta}>({
274
+ type: MiniappRequestType.BLOB_OPEN_READ,
275
+ key,
276
+ })
277
+ return new BlobReader(this.session, res.handle, res.meta)
278
+ }
279
+
280
+ /** Read a whole blob into memory as bytes, or null if absent. Throws past the cap — stream big ones. */
281
+ async bytes(key: string): Promise<Uint8Array | null> {
282
+ let reader: BlobReader
283
+ try {
284
+ reader = await this.createReadStream(key)
285
+ } catch {
286
+ return null
287
+ }
288
+ const parts: Uint8Array[] = []
289
+ let total = 0
290
+ try {
291
+ for (;;) {
292
+ const {bytes, done} = await reader.read()
293
+ if (bytes.length) {
294
+ total += bytes.length
295
+ if (total > BLOB_READ_ALL_MAX_BYTES) {
296
+ throw new Error(`Blob "${key}" exceeds the in-memory read cap — stream it with createReadStream()`)
297
+ }
298
+ parts.push(bytes)
299
+ }
300
+ if (done) break
301
+ }
302
+ } finally {
303
+ await reader.close().catch(() => {})
304
+ }
305
+ const out = new Uint8Array(total)
306
+ let at = 0
307
+ for (const p of parts) {
308
+ out.set(p, at)
309
+ at += p.length
310
+ }
311
+ return out
312
+ }
313
+
314
+ // ── manage ───────────────────────────────────────────────────────────────
315
+
316
+ /** Per-app usage + the quota ceiling, in bytes. */
317
+ usage(): Promise<{bytes: number; count: number; quotaBytes: number}> {
318
+ return this.session.sendRequest<{bytes: number; count: number; quotaBytes: number}>({
319
+ type: MiniappRequestType.BLOB_USAGE,
320
+ })
321
+ }
322
+
323
+ /** Delete the blob under `key`. No-op if absent. */
324
+ async delete(key: string): Promise<void> {
325
+ await this.session.sendRequest<void>({type: MiniappRequestType.BLOB_DELETE, key})
326
+ }
327
+
328
+ /** Delete every blob this miniapp owns. */
329
+ async clear(): Promise<void> {
330
+ await this.session.sendRequest<void>({type: MiniappRequestType.BLOB_CLEAR})
331
+ }
332
+
333
+ /** Share the blob via the OS share sheet (host shares from disk — no bytes cross the bridge). */
334
+ async share(key: string): Promise<{success: boolean; cancelled?: boolean}> {
335
+ const res = await this.session.sendRequest<{success: boolean; cancelled?: boolean}>({
336
+ type: MiniappRequestType.BLOB_SHARE,
337
+ key,
338
+ })
339
+ return res ?? {success: false}
340
+ }
341
+ }