@mentra/miniapp 3.2.0-dev.115 → 3.2.0-dev.120

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.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * @fileoverview MeetingModule — phone-native meeting (ACS Teams).
3
+ *
4
+ * V1 token pass-through is deliberate technical debt (identity ticket):
5
+ * later, join(meetingUrl, whepUrl) and the host fetches the credential from
6
+ * Porter. Miniapps must not persist the token.
7
+ */
8
+
9
+ import {MiniappErrorCode, MiniappRequestType} from "../protocol"
10
+ import type {MiniappRequestError} from "../session"
11
+ import {MiniappSession} from "../session"
12
+ import type {UnsubscribeFn} from "./events"
13
+
14
+ export const MEETING_HOST_UPDATE_MESSAGE = "Update the Mentra App to use Teams calling"
15
+
16
+ export type MeetingProvider = "acs-teams"
17
+
18
+ export type MeetingPhase = "idle" | "connecting" | "lobby" | "connected" | "disconnected" | "error"
19
+
20
+ export interface MeetingVideoSource {
21
+ type: "whep"
22
+ url: string
23
+ }
24
+
25
+ /** Advertised ACS outgoing format. Omitted hosts keep 1280×720@15. */
26
+ export interface MeetingOutgoingVideo {
27
+ width: number
28
+ height: number
29
+ fps: number
30
+ maxBitrateBps: number
31
+ }
32
+
33
+ export interface MeetingJoinOptions {
34
+ provider: MeetingProvider
35
+ meetingUrl: string
36
+ videoSource: MeetingVideoSource
37
+ /** V1-only: Porter-minted ACS guest token. Do not persist. */
38
+ token: string
39
+ displayName?: string
40
+ video?: MeetingOutgoingVideo
41
+ }
42
+
43
+ export type MeetingParticipantState = "idle" | "connecting" | "connected" | "lobby" | "hold" | "disconnected"
44
+
45
+ /** A remote participant as reported by the phone-native meeting client. */
46
+ export interface MeetingParticipant {
47
+ /** Stable provider identifier (ACS raw id). */
48
+ id: string
49
+ displayName: string | null
50
+ state: MeetingParticipantState
51
+ isMuted: boolean
52
+ isSpeaking: boolean
53
+ }
54
+
55
+ export interface MeetingState {
56
+ state: MeetingPhase
57
+ muted: boolean
58
+ error?: string
59
+ meetingUrl?: string
60
+ provider?: MeetingProvider
61
+ audioSource?: "glasses" | "phone"
62
+ audioSourceReason?:
63
+ | "explicit"
64
+ | "current-mic"
65
+ | "ranking"
66
+ | "fallback-glasses-connected"
67
+ | "fallback-no-glasses"
68
+ activeStream?: "none" | "virtual" | "local"
69
+ audioSafety?: "safe" | "degraded" | "unsafe"
70
+ /**
71
+ * Health of the glasses video the phone is forwarding into the meeting.
72
+ * `live` means a frame reached the meeting client, so it is the only honest
73
+ * "remote participants can see the camera" signal — the WHEP subscription
74
+ * answers seconds earlier. Omitted by hosts that predate the field, which
75
+ * must be read as "unknown", never as "not live".
76
+ */
77
+ mediaSource?: MeetingMediaSource
78
+ /** Remote roster. Omitted by hosts that predate participant reporting. */
79
+ participants?: MeetingParticipant[]
80
+ }
81
+
82
+ export type MeetingMediaSource = "idle" | "connecting" | "live" | "failed"
83
+
84
+ const PARTICIPANT_STATES: ReadonlySet<string> = new Set(["idle", "connecting", "connected", "lobby", "hold", "disconnected"])
85
+
86
+ const MEDIA_SOURCES: ReadonlySet<string> = new Set(["idle", "connecting", "live", "failed"])
87
+
88
+ /** Tolerant parse of a host `mediaSource`. Unknown values read as unknown. */
89
+ export function parseMeetingMediaSource(raw: unknown): MeetingMediaSource | undefined {
90
+ return MEDIA_SOURCES.has(String(raw)) ? (raw as MeetingMediaSource) : undefined
91
+ }
92
+
93
+ /** Tolerant parse of a host `participants` payload. Unknown shapes are skipped. */
94
+ export function parseMeetingParticipants(raw: unknown): MeetingParticipant[] | undefined {
95
+ if (!Array.isArray(raw)) return undefined
96
+ const result: MeetingParticipant[] = []
97
+ for (const entry of raw) {
98
+ if (!entry || typeof entry !== "object") continue
99
+ const value = entry as Record<string, unknown>
100
+ if (typeof value.id !== "string" || !value.id) continue
101
+ result.push({
102
+ id: value.id,
103
+ displayName: typeof value.displayName === "string" && value.displayName ? value.displayName : null,
104
+ state: PARTICIPANT_STATES.has(String(value.state)) ? (value.state as MeetingParticipantState) : "idle",
105
+ isMuted: Boolean(value.isMuted),
106
+ isSpeaking: Boolean(value.isSpeaking),
107
+ })
108
+ }
109
+ return result
110
+ }
111
+
112
+ export type MeetingStateHandler = (state: MeetingState) => void
113
+
114
+ function isMiniappRequestError(error: unknown): error is MiniappRequestError {
115
+ return Boolean(error && typeof error === "object" && "code" in error)
116
+ }
117
+
118
+ function mapHostError(error: unknown): never {
119
+ if (isMiniappRequestError(error) && error.code === MiniappErrorCode.NOT_IMPLEMENTED) {
120
+ throw {code: MiniappErrorCode.NOT_IMPLEMENTED, message: MEETING_HOST_UPDATE_MESSAGE}
121
+ }
122
+ throw error
123
+ }
124
+
125
+ export class MeetingModule {
126
+ private _state: MeetingState = {state: "idle", muted: false}
127
+
128
+ constructor(private readonly session: MiniappSession) {}
129
+
130
+ get state(): MeetingState {
131
+ return {...this._state}
132
+ }
133
+
134
+ /**
135
+ * Join a Teams meeting via the phone ACS client.
136
+ * Resolves once the host has accepted the join (state may still be connecting/lobby).
137
+ */
138
+ async join(options: MeetingJoinOptions): Promise<MeetingState> {
139
+ if (options.provider !== "acs-teams") {
140
+ throw {code: MiniappErrorCode.INVALID_ARGUMENT, message: `Unsupported meeting provider: ${options.provider}`}
141
+ }
142
+ if (!options.meetingUrl?.trim()) {
143
+ throw {code: MiniappErrorCode.INVALID_ARGUMENT, message: "meetingUrl is required"}
144
+ }
145
+ if (options.videoSource?.type !== "whep" || !options.videoSource.url?.trim()) {
146
+ throw {code: MiniappErrorCode.INVALID_ARGUMENT, message: "videoSource must be a WHEP URL"}
147
+ }
148
+ if (!options.token?.trim()) {
149
+ throw {code: MiniappErrorCode.INVALID_ARGUMENT, message: "token is required"}
150
+ }
151
+ try {
152
+ const result = await this.session.sendRequest<MeetingState | null>(
153
+ {
154
+ type: MiniappRequestType.MEETING_JOIN,
155
+ provider: options.provider,
156
+ meetingUrl: options.meetingUrl,
157
+ videoSource: options.videoSource,
158
+ token: options.token,
159
+ displayName: options.displayName,
160
+ ...(options.video ? {video: options.video} : {}),
161
+ },
162
+ {timeoutMs: 0},
163
+ )
164
+ if (result) this._applyState(result)
165
+ return this.state
166
+ } catch (error) {
167
+ mapHostError(error)
168
+ }
169
+ }
170
+
171
+ async leave(): Promise<void> {
172
+ try {
173
+ await this.session.sendRequest<void>({type: MiniappRequestType.MEETING_LEAVE})
174
+ } catch (error) {
175
+ mapHostError(error)
176
+ }
177
+ }
178
+
179
+ async setMuted(muted: boolean): Promise<void> {
180
+ try {
181
+ const result = await this.session.sendRequest<MeetingState | null>({
182
+ type: MiniappRequestType.MEETING_SET_MUTED,
183
+ muted,
184
+ })
185
+ if (result) this._applyState(result)
186
+ } catch (error) {
187
+ mapHostError(error)
188
+ }
189
+ }
190
+
191
+ async updateVideoSource(source: MeetingVideoSource): Promise<void> {
192
+ if (source?.type !== "whep" || !source.url?.trim()) {
193
+ throw {code: MiniappErrorCode.INVALID_ARGUMENT, message: "videoSource must be a WHEP URL"}
194
+ }
195
+ try {
196
+ await this.session.sendRequest<void>({
197
+ type: MiniappRequestType.MEETING_UPDATE_VIDEO_SOURCE,
198
+ videoSource: source,
199
+ })
200
+ } catch (error) {
201
+ mapHostError(error)
202
+ }
203
+ }
204
+
205
+ async getState(): Promise<MeetingState> {
206
+ try {
207
+ const result = await this.session.sendRequest<MeetingState | null>({
208
+ type: MiniappRequestType.MEETING_GET_STATE,
209
+ })
210
+ if (result) this._applyState(result)
211
+ return this.state
212
+ } catch (error) {
213
+ mapHostError(error)
214
+ }
215
+ }
216
+
217
+ onState(handler: MeetingStateHandler): UnsubscribeFn {
218
+ return this.session.on("meetingState", handler)
219
+ }
220
+
221
+ /** @internal — applied by MiniappSession on inbound MEETING_STATE. */
222
+ _applyState(event: MeetingState): void {
223
+ this._state = {
224
+ state: event.state,
225
+ muted: Boolean(event.muted),
226
+ error: event.error,
227
+ meetingUrl: event.meetingUrl,
228
+ provider: event.provider,
229
+ audioSource: event.audioSource,
230
+ audioSourceReason: event.audioSourceReason,
231
+ activeStream: event.activeStream,
232
+ audioSafety: event.audioSafety,
233
+ mediaSource: parseMeetingMediaSource(event.mediaSource),
234
+ participants: parseMeetingParticipants(event.participants),
235
+ }
236
+ }
237
+ }
@@ -83,6 +83,11 @@ export interface StartStreamOptions {
83
83
  ingest?: "srt" | "whip" | "rtmp"
84
84
  /** Optional Bearer token for direct WHIP Authorization (custom authenticated endpoints). */
85
85
  authToken?: string
86
+ /**
87
+ * When false, glasses skip mic capture for this WHIP session. Fixed for the
88
+ * lifetime of the stream. Defaults to true. Older Mentra Apps ignore this.
89
+ */
90
+ captureAudio?: boolean
86
91
  }
87
92
 
88
93
  export interface StreamResult {
@@ -146,6 +151,7 @@ export class StreamModule {
146
151
  audio: options.audio,
147
152
  sound: options.sound ?? true,
148
153
  ...(options.authToken ? {authToken: options.authToken} : {}),
154
+ ...(typeof options.captureAudio === "boolean" ? {captureAudio: options.captureAudio} : {}),
149
155
  })
150
156
  }
151
157
  return this.session.sendRequest<StreamResult>({
@@ -155,6 +161,7 @@ export class StreamModule {
155
161
  audio: options.audio,
156
162
  sound: options.sound ?? true,
157
163
  ingest: options.ingest,
164
+ ...(typeof options.captureAudio === "boolean" ? {captureAudio: options.captureAudio} : {}),
158
165
  })
159
166
  }
160
167
 
package/src/protocol.ts CHANGED
@@ -205,6 +205,16 @@ export enum MiniappRequestType {
205
205
  ACTION_INVOKE = "miniapp_action_invoke",
206
206
  /** Target → host: the result of a delivered ACTION_CALL, correlated by callId. */
207
207
  ACTION_RESULT = "miniapp_action_result",
208
+
209
+ /**
210
+ * Phone-native meeting (ACS Teams). Join/leave/mute live in the MentraOS
211
+ * host so the miniapp never holds the ACS Calling SDK.
212
+ */
213
+ MEETING_JOIN = "miniapp_meeting_join",
214
+ MEETING_LEAVE = "miniapp_meeting_leave",
215
+ MEETING_SET_MUTED = "miniapp_meeting_set_muted",
216
+ MEETING_UPDATE_VIDEO_SOURCE = "miniapp_meeting_update_video_source",
217
+ MEETING_GET_STATE = "miniapp_meeting_get_state",
208
218
  }
209
219
 
210
220
  // ============================================================================
@@ -261,6 +271,12 @@ export enum MiniappResponseType {
261
271
  */
262
272
  ACTION_CALL = "miniapp_action_call",
263
273
 
274
+ /**
275
+ * Push: native meeting state changed. Carries {state, muted?, error?}.
276
+ * See MeetingModule.onState().
277
+ */
278
+ MEETING_STATE = "miniapp_meeting_state",
279
+
264
280
  /**
265
281
  * Push: phone is about to tear down the miniapp's session. Gives the SDK
266
282
  * a brief window (~50ms grace on the phone side) to fire one last
package/src/session.ts CHANGED
@@ -45,6 +45,7 @@ import {SystemModule} from "./modules/system"
45
45
  import {MiniappsModule} from "./modules/miniapps"
46
46
  import {ActionsModule} from "./modules/actions"
47
47
  import {BlobModule} from "./modules/blob"
48
+ import {MeetingModule, parseMeetingMediaSource, parseMeetingParticipants} from "./modules/meeting"
48
49
 
49
50
  // ---------------------------------------------------------------------------
50
51
  // Public types
@@ -105,6 +106,13 @@ export interface ConnectAckPayload {
105
106
  permissions?: PermissionRecord
106
107
  /** Miniapp-scoped backend auth. Never a Core or runtime token. */
107
108
  auth?: MiniappAuthState
109
+ /** Host-advertised optional features. Absent on older Mentra Apps. */
110
+ hostFeatures?: HostFeatures
111
+ }
112
+
113
+ export interface HostFeatures {
114
+ /** Host honors `startStream({captureAudio})` for the lifetime of a WHIP session. */
115
+ captureAudio?: boolean
108
116
  }
109
117
 
110
118
  export interface MiniappAuthState {
@@ -191,6 +199,7 @@ type SessionEmitterEvents = {
191
199
  colorScheme: (scheme: MiniappColorScheme) => void
192
200
  permissions: (perms: PermissionRecord) => void
193
201
  speakerState: (event: import("./modules/speaker").SpeakerStateEvent) => void
202
+ meetingState: (event: import("./modules/meeting").MeetingState) => void
194
203
  auth: (auth: MiniappAuthState) => void
195
204
  }
196
205
 
@@ -211,6 +220,7 @@ export class MiniappSession<TChannels extends object = any> {
211
220
  */
212
221
  public readonly events: EventManager
213
222
  public readonly speaker: SpeakerModule
223
+ public readonly meeting: MeetingModule
214
224
  public readonly camera: CameraModule
215
225
  public readonly cloud: CloudModule
216
226
  public readonly dashboard: DashboardAPI
@@ -256,6 +266,11 @@ export class MiniappSession<TChannels extends object = any> {
256
266
 
257
267
  /** Phone-declared glasses capabilities. Null until CONNECT_ACK arrives. */
258
268
  public capabilities: GlassesCapabilities | null = null
269
+ /**
270
+ * Host-advertised optional features. Null until CONNECT_ACK. Older Mentra Apps
271
+ * omit this, so miniapps must not assume `captureAudio` is honored.
272
+ */
273
+ public hostFeatures: HostFeatures | null = null
259
274
  public userId = ""
260
275
  public packageName = ""
261
276
  public visibility: MiniappVisibility = "foreground"
@@ -308,6 +323,7 @@ export class MiniappSession<TChannels extends object = any> {
308
323
  this.auth = new AuthModule(this)
309
324
  this.events = new EventManager(this)
310
325
  this.speaker = new SpeakerModule(this)
326
+ this.meeting = new MeetingModule(this)
311
327
  this.camera = new CameraModule(this)
312
328
  this.cloud = new CloudModule(this)
313
329
  this.dashboard = new DashboardAPI(this)
@@ -619,6 +635,7 @@ export class MiniappSession<TChannels extends object = any> {
619
635
  this.userId = ack.userId ?? ""
620
636
  if (ack.packageName) this.packageName = ack.packageName
621
637
  this.capabilities = ack.capabilities ?? null
638
+ this.hostFeatures = ack.hostFeatures ?? null
622
639
  if (ack.visibility) this.visibility = ack.visibility
623
640
  if (ack.colorScheme === "light" || ack.colorScheme === "dark") {
624
641
  this.colorScheme = ack.colorScheme
@@ -667,6 +684,27 @@ export class MiniappSession<TChannels extends object = any> {
667
684
  return
668
685
  }
669
686
 
687
+ case MiniappResponseType.MEETING_STATE: {
688
+ const state = payload.state as import("./modules/meeting").MeetingPhase | undefined
689
+ if (!state) return
690
+ const event: import("./modules/meeting").MeetingState = {
691
+ state,
692
+ muted: Boolean(payload.muted),
693
+ error: payload.error as string | undefined,
694
+ meetingUrl: payload.meetingUrl as string | undefined,
695
+ provider: payload.provider as import("./modules/meeting").MeetingProvider | undefined,
696
+ audioSource: payload.audioSource as import("./modules/meeting").MeetingState["audioSource"],
697
+ audioSourceReason: payload.audioSourceReason as import("./modules/meeting").MeetingState["audioSourceReason"],
698
+ activeStream: payload.activeStream as import("./modules/meeting").MeetingState["activeStream"],
699
+ audioSafety: payload.audioSafety as import("./modules/meeting").MeetingState["audioSafety"],
700
+ mediaSource: parseMeetingMediaSource(payload.mediaSource),
701
+ participants: parseMeetingParticipants(payload.participants),
702
+ }
703
+ this.meeting._applyState(event)
704
+ this.emitter.emit("meetingState", event)
705
+ return
706
+ }
707
+
670
708
  case MiniappRequestType.PING: {
671
709
  // Phone → miniapp keepalive ping. Auto-reply with PONG.
672
710
  const pong: object = {type: MiniappResponseType.PONG}