@mentra/engine 3.2.0-dev.116 → 3.2.0-dev.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/engine.d.ts.map +1 -1
- package/build/engine.js +7 -2
- package/build/engine.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/runtime/streamConfig.d.ts +7 -0
- package/build/runtime/streamConfig.d.ts.map +1 -1
- package/build/runtime/streamConfig.js +7 -0
- package/build/runtime/streamConfig.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +154 -0
- package/build/services/AcsMeetingService.d.ts.map +1 -0
- package/build/services/AcsMeetingService.js +477 -0
- package/build/services/AcsMeetingService.js.map +1 -0
- package/build/services/AppRegistry.d.ts +9 -0
- package/build/services/AppRegistry.d.ts.map +1 -1
- package/build/services/AppRegistry.js +22 -0
- package/build/services/AppRegistry.js.map +1 -1
- package/build/services/DeviceEventRouter.d.ts.map +1 -1
- package/build/services/DeviceEventRouter.js +12 -3
- package/build/services/DeviceEventRouter.js.map +1 -1
- package/build/services/GlassesSettingsSync.d.ts.map +1 -1
- package/build/services/GlassesSettingsSync.js +11 -1
- package/build/services/GlassesSettingsSync.js.map +1 -1
- package/build/services/GlassesStatusProjection.d.ts +12 -0
- package/build/services/GlassesStatusProjection.d.ts.map +1 -1
- package/build/services/GlassesStatusProjection.js +23 -0
- package/build/services/GlassesStatusProjection.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +12 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +184 -2
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MentraJSRouter.d.ts.map +1 -1
- package/build/services/MentraJSRouter.js +8 -1
- package/build/services/MentraJSRouter.js.map +1 -1
- package/build/services/MiniappLauncher.d.ts +3 -1
- package/build/services/MiniappLauncher.d.ts.map +1 -1
- package/build/services/MiniappLauncher.js +66 -50
- package/build/services/MiniappLauncher.js.map +1 -1
- package/build/services/PhoneCameraFovCoordinator.d.ts +18 -0
- package/build/services/PhoneCameraFovCoordinator.d.ts.map +1 -1
- package/build/services/PhoneCameraFovCoordinator.js +36 -0
- package/build/services/PhoneCameraFovCoordinator.js.map +1 -1
- package/build/services/PhoneStreamCoordinator.d.ts +67 -1
- package/build/services/PhoneStreamCoordinator.d.ts.map +1 -1
- package/build/services/PhoneStreamCoordinator.js +204 -15
- package/build/services/PhoneStreamCoordinator.js.map +1 -1
- package/build/services/StreamLifecycleController.d.ts +23 -5
- package/build/services/StreamLifecycleController.d.ts.map +1 -1
- package/build/services/StreamLifecycleController.js +31 -10
- package/build/services/StreamLifecycleController.js.map +1 -1
- package/build/services/acsAudioSource.d.ts +24 -0
- package/build/services/acsAudioSource.d.ts.map +1 -0
- package/build/services/acsAudioSource.js +37 -0
- package/build/services/acsAudioSource.js.map +1 -0
- package/build/utils/devMiniappLaunch.d.ts.map +1 -1
- package/build/utils/devMiniappLaunch.js +4 -0
- package/build/utils/devMiniappLaunch.js.map +1 -1
- package/build/utils/devMiniappSnapshot.d.ts +46 -0
- package/build/utils/devMiniappSnapshot.d.ts.map +1 -0
- package/build/utils/devMiniappSnapshot.js +94 -0
- package/build/utils/devMiniappSnapshot.js.map +1 -0
- package/package.json +7 -6
- package/src/engine.ts +10 -2
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/index.ts +4 -0
- package/src/runtime/streamConfig.ts +13 -0
- package/src/services/AcsMeetingService.ts +570 -0
- package/src/services/AppRegistry.ts +23 -0
- package/src/services/DeviceEventRouter.ts +12 -3
- package/src/services/GlassesSettingsSync.ts +13 -3
- package/src/services/GlassesStatusProjection.ts +28 -0
- package/src/services/LocalMiniappRuntime.ts +214 -2
- package/src/services/MentraJSRouter.ts +7 -1
- package/src/services/MiniappLauncher.ts +73 -50
- package/src/services/PhoneCameraFovCoordinator.ts +34 -0
- package/src/services/PhoneStreamCoordinator.ts +232 -19
- package/src/services/StreamLifecycleController.ts +42 -12
- package/src/services/acsAudioSource.ts +53 -0
- package/src/utils/devMiniappLaunch.ts +4 -0
- package/src/utils/devMiniappSnapshot.ts +119 -0
|
@@ -14,6 +14,34 @@
|
|
|
14
14
|
import BluetoothSdk, {type PublicGlassesStatus} from "@mentra/bluetooth-sdk"
|
|
15
15
|
import {useCoreStore} from "../stores/core"
|
|
16
16
|
import {useGlassesStore} from "../stores/glasses"
|
|
17
|
+
import {isGlassesConnected} from "./GlassesReadiness"
|
|
18
|
+
|
|
19
|
+
/** Miniapp `session.glasses.onConnection` payload. */
|
|
20
|
+
export type MiniappConnectionData = {
|
|
21
|
+
connected: boolean
|
|
22
|
+
modelName?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Native `glasses_status` is a store delta (`connection.state`, battery, wifi…).
|
|
27
|
+
* Miniapps subscribe to a boolean `connected` field. Forwarding the raw status
|
|
28
|
+
* makes `Boolean(data.connected)` false on every heartbeat while the Mentra App
|
|
29
|
+
* still shows the glasses linked.
|
|
30
|
+
*/
|
|
31
|
+
export function toMiniappConnectionData(status: unknown): MiniappConnectionData | null {
|
|
32
|
+
if (!status || typeof status !== "object") return null
|
|
33
|
+
const rec = status as Partial<PublicGlassesStatus> & {connected?: boolean; modelName?: string}
|
|
34
|
+
if (typeof rec.connected === "boolean") {
|
|
35
|
+
const modelName = rec.modelName || rec.deviceModel
|
|
36
|
+
return {connected: rec.connected, ...(modelName ? {modelName} : {})}
|
|
37
|
+
}
|
|
38
|
+
if (!rec.connection) return null
|
|
39
|
+
const modelName = rec.deviceModel
|
|
40
|
+
return {
|
|
41
|
+
connected: isGlassesConnected(rec.connection),
|
|
42
|
+
...(modelName ? {modelName} : {}),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
17
45
|
|
|
18
46
|
let unsubs: Array<() => void> = []
|
|
19
47
|
let projectionRunId = 0
|
|
@@ -34,6 +34,7 @@ import {BgTimer} from "../utils/timers"
|
|
|
34
34
|
import devServerBridge from "./DevServerBridge"
|
|
35
35
|
import {islandNotifications} from "./NotificationsEmitter"
|
|
36
36
|
import {isGlassesConnected} from "./GlassesReadiness"
|
|
37
|
+
import {toMiniappConnectionData} from "./GlassesStatusProjection"
|
|
37
38
|
import audioPlaybackService from "./AudioPlaybackService"
|
|
38
39
|
import {phoneLocationService} from "./PhoneLocationService"
|
|
39
40
|
import {useGlassesStore} from "../stores/glasses"
|
|
@@ -67,7 +68,7 @@ import {
|
|
|
67
68
|
} from "../runtime/config"
|
|
68
69
|
import {getAnalytics, getUiSeams} from "../runtime/bootstrap"
|
|
69
70
|
import {invokeScanQrSeam} from "../runtime/scanQrSeam"
|
|
70
|
-
import {normalizeStreamAudioConfig, normalizeStreamVideoConfig} from "../runtime/streamConfig"
|
|
71
|
+
import {normalizeStreamAudioConfig, normalizeStreamVideoConfig, resolveCaptureAudio} from "../runtime/streamConfig"
|
|
71
72
|
import {toLanguageHint} from "@mentra/cloud-protocol/languages"
|
|
72
73
|
import type {AudioSubscription, LanguageSource, TranscriptionData, TranslationData} from "@mentra/cloud-protocol"
|
|
73
74
|
import {buildMiniappManifestSnapshot, type MiniappRuntimeDiagnosticSnapshot} from "../utils/miniappDiagnostics"
|
|
@@ -80,6 +81,7 @@ import {resolveForegroundLocationPermission} from "./ForegroundLocationPermissio
|
|
|
80
81
|
import {advanceMiniappPingLiveness} from "./MiniappLiveness"
|
|
81
82
|
import {listPhoneCalendarEvents, PhoneCalendarError} from "./PhoneCalendarService"
|
|
82
83
|
import {LocalMiniappStorage} from "./LocalMiniappStorage"
|
|
84
|
+
import acsMeetingService, {parseAcsOutgoingVideo, resolveAcsAudioSource} from "./AcsMeetingService"
|
|
83
85
|
|
|
84
86
|
// =============================================================================
|
|
85
87
|
// Types
|
|
@@ -758,6 +760,22 @@ class LocalMiniappRuntime {
|
|
|
758
760
|
this.replaceStreamSubscribers(packageName, existing.subscriptions, [])
|
|
759
761
|
this.recomputeMicRequirements()
|
|
760
762
|
this.updateCloudSubscriptions()
|
|
763
|
+
// Managed streams and ACS meetings deliberately survive a respawn: the new
|
|
764
|
+
// incarnation re-adopts them on restore (Mentra Call does exactly this) so a
|
|
765
|
+
// WebView reload does not drop the wearer out of a live call. An unmanaged
|
|
766
|
+
// stream cannot be re-adopted — startUnmanaged rejects while any stream is
|
|
767
|
+
// active, even for the same owner — so release it or the respawned miniapp
|
|
768
|
+
// is stuck behind STREAM_ALREADY_ACTIVE until the process restarts.
|
|
769
|
+
const streamSnapshot = phoneStreamCoordinator.getDiagnosticSnapshot()
|
|
770
|
+
if (
|
|
771
|
+
streamSnapshot.active === true &&
|
|
772
|
+
streamSnapshot.kind === "unmanaged" &&
|
|
773
|
+
streamSnapshot.ownerPackageName === packageName
|
|
774
|
+
) {
|
|
775
|
+
void phoneStreamCoordinator.stop(packageName).catch((error) => {
|
|
776
|
+
console.warn(`${LOG_TAG}: failed to stop unmanaged stream while replacing ${packageName}`, error)
|
|
777
|
+
})
|
|
778
|
+
}
|
|
761
779
|
}
|
|
762
780
|
this.connectedApps.set(packageName, {
|
|
763
781
|
subscriptions: new Set(),
|
|
@@ -953,6 +971,9 @@ class LocalMiniappRuntime {
|
|
|
953
971
|
void phoneVideoCoordinator.stopForApp(packageName).catch((error) => {
|
|
954
972
|
console.warn(`${LOG_TAG}: failed to stop video recording for ${packageName} on unregister`, error)
|
|
955
973
|
})
|
|
974
|
+
void acsMeetingService.leaveIfOwner(packageName).catch((error) => {
|
|
975
|
+
console.warn(`${LOG_TAG}: failed to leave ACS meeting for ${packageName} on unregister`, error)
|
|
976
|
+
})
|
|
956
977
|
|
|
957
978
|
// Detach the per-app nav event forwarder but leave the native nav session
|
|
958
979
|
// running. The user may have just closed the mini-app UI and will reopen
|
|
@@ -1307,6 +1328,21 @@ class LocalMiniappRuntime {
|
|
|
1307
1328
|
case MiniappRequestType.MANAGED_STREAM_STOP:
|
|
1308
1329
|
void this.handleManagedStreamStop(packageName, payload, requestId)
|
|
1309
1330
|
break
|
|
1331
|
+
case MiniappRequestType.MEETING_JOIN:
|
|
1332
|
+
void this.handleMeetingJoin(packageName, payload, requestId)
|
|
1333
|
+
break
|
|
1334
|
+
case MiniappRequestType.MEETING_LEAVE:
|
|
1335
|
+
void this.handleMeetingLeave(packageName, requestId)
|
|
1336
|
+
break
|
|
1337
|
+
case MiniappRequestType.MEETING_SET_MUTED:
|
|
1338
|
+
void this.handleMeetingSetMuted(packageName, payload, requestId)
|
|
1339
|
+
break
|
|
1340
|
+
case MiniappRequestType.MEETING_UPDATE_VIDEO_SOURCE:
|
|
1341
|
+
void this.handleMeetingUpdateVideoSource(packageName, payload, requestId)
|
|
1342
|
+
break
|
|
1343
|
+
case MiniappRequestType.MEETING_GET_STATE:
|
|
1344
|
+
void this.handleMeetingGetState(packageName, requestId)
|
|
1345
|
+
break
|
|
1310
1346
|
case REQUEST_WIFI_SETUP_TYPE:
|
|
1311
1347
|
void this.handleRequestWifiSetup(packageName, payload, requestId)
|
|
1312
1348
|
break
|
|
@@ -1409,6 +1445,7 @@ class LocalMiniappRuntime {
|
|
|
1409
1445
|
packageName,
|
|
1410
1446
|
capabilities,
|
|
1411
1447
|
permissions: declaredPermissions,
|
|
1448
|
+
hostFeatures: {captureAudio: true},
|
|
1412
1449
|
...(initialAuth ? {auth: initialAuth} : {}),
|
|
1413
1450
|
},
|
|
1414
1451
|
requestId,
|
|
@@ -1797,7 +1834,10 @@ class LocalMiniappRuntime {
|
|
|
1797
1834
|
this.sendToMiniapp(packageName, {
|
|
1798
1835
|
type: MiniappResponseType.EVENT,
|
|
1799
1836
|
streamType: "glasses_connection",
|
|
1800
|
-
data:
|
|
1837
|
+
data: {
|
|
1838
|
+
connected: glassesState.connected,
|
|
1839
|
+
modelName: glassesState.deviceModel || undefined,
|
|
1840
|
+
},
|
|
1801
1841
|
})
|
|
1802
1842
|
} else if (stream === "glasses_wifi") {
|
|
1803
1843
|
// Snapshot the current glasses Wi-Fi state on subscribe (like battery).
|
|
@@ -1816,6 +1856,7 @@ class LocalMiniappRuntime {
|
|
|
1816
1856
|
streamType: "glasses_wifi",
|
|
1817
1857
|
data: {
|
|
1818
1858
|
connected,
|
|
1859
|
+
linkConnected: glassesState.connected === true,
|
|
1819
1860
|
ssid: connected ? wifi?.ssid : undefined,
|
|
1820
1861
|
localIp: connected ? wifi?.localIp : undefined,
|
|
1821
1862
|
timestamp: Date.now(),
|
|
@@ -3241,6 +3282,30 @@ class LocalMiniappRuntime {
|
|
|
3241
3282
|
// Photo + streaming handlers (cloud-coordinated)
|
|
3242
3283
|
// ===========================================================================
|
|
3243
3284
|
|
|
3285
|
+
/**
|
|
3286
|
+
* Manifest permission gate shared by the camera-and-mic RPCs (streams, meetings).
|
|
3287
|
+
* Sends PERMISSION_NOT_DECLARED and returns false when the miniapp did not declare
|
|
3288
|
+
* `permission`; the caller must return without doing the work.
|
|
3289
|
+
*/
|
|
3290
|
+
private requireManifestPermission(
|
|
3291
|
+
packageName: string,
|
|
3292
|
+
requestId: string | undefined,
|
|
3293
|
+
permission: "CAMERA" | "MICROPHONE",
|
|
3294
|
+
purpose: string,
|
|
3295
|
+
operation: MiniappRequestType,
|
|
3296
|
+
): boolean {
|
|
3297
|
+
const app = this.connectedApps.get(packageName)
|
|
3298
|
+
if (app?.installedManifest?.permissions?.some((p) => p.type === permission)) return true
|
|
3299
|
+
logPermissionNotDeclared(packageName, permission, purpose, `{"type": "${permission}"}`)
|
|
3300
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3301
|
+
code: MiniappErrorCode.PERMISSION_NOT_DECLARED,
|
|
3302
|
+
message: `${permission} permission not declared in miniapp.json. Add {"type": "${permission}"} to the "permissions" array.`,
|
|
3303
|
+
permission,
|
|
3304
|
+
operation,
|
|
3305
|
+
})
|
|
3306
|
+
return false
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3244
3309
|
private async handlePhoto(packageName: string, payload: Record<string, unknown>, requestId?: string): Promise<void> {
|
|
3245
3310
|
// Manifest CAMERA permission gate.
|
|
3246
3311
|
const app = this.connectedApps.get(packageName)
|
|
@@ -3399,6 +3464,10 @@ class LocalMiniappRuntime {
|
|
|
3399
3464
|
})
|
|
3400
3465
|
return
|
|
3401
3466
|
}
|
|
3467
|
+
// A stream is the glasses camera (and mic) leaving the device; gate it like a photo.
|
|
3468
|
+
if (!this.requireManifestPermission(packageName, requestId, "CAMERA", "to stream the camera", MiniappRequestType.STREAM_START)) {
|
|
3469
|
+
return
|
|
3470
|
+
}
|
|
3402
3471
|
try {
|
|
3403
3472
|
const result = await streaming.startUnmanaged(packageName, {
|
|
3404
3473
|
streamUrl: payload.streamUrl as string,
|
|
@@ -3406,6 +3475,7 @@ class LocalMiniappRuntime {
|
|
|
3406
3475
|
audio: normalizeStreamAudioConfig(payload.audio),
|
|
3407
3476
|
sound: payload.sound as boolean | undefined,
|
|
3408
3477
|
authToken: typeof payload.authToken === "string" ? payload.authToken : undefined,
|
|
3478
|
+
captureAudio: resolveCaptureAudio(payload.captureAudio, resolveAcsAudioSource().source),
|
|
3409
3479
|
})
|
|
3410
3480
|
this.sendResult(packageName, requestId, true, result)
|
|
3411
3481
|
} catch (err) {
|
|
@@ -3455,6 +3525,17 @@ class LocalMiniappRuntime {
|
|
|
3455
3525
|
})
|
|
3456
3526
|
return
|
|
3457
3527
|
}
|
|
3528
|
+
if (
|
|
3529
|
+
!this.requireManifestPermission(
|
|
3530
|
+
packageName,
|
|
3531
|
+
requestId,
|
|
3532
|
+
"CAMERA",
|
|
3533
|
+
"to stream the camera",
|
|
3534
|
+
MiniappRequestType.MANAGED_STREAM_START,
|
|
3535
|
+
)
|
|
3536
|
+
) {
|
|
3537
|
+
return
|
|
3538
|
+
}
|
|
3458
3539
|
try {
|
|
3459
3540
|
const result = await streaming.startManaged(packageName, {
|
|
3460
3541
|
restreamDestinations: payload.restreamDestinations as Array<string | {url: string; name?: string}> | undefined,
|
|
@@ -3462,6 +3543,7 @@ class LocalMiniappRuntime {
|
|
|
3462
3543
|
audio: normalizeStreamAudioConfig(payload.audio),
|
|
3463
3544
|
sound: payload.sound as boolean | undefined,
|
|
3464
3545
|
ingest: payload.ingest as "srt" | "whip" | undefined,
|
|
3546
|
+
captureAudio: resolveCaptureAudio(payload.captureAudio, resolveAcsAudioSource().source),
|
|
3465
3547
|
})
|
|
3466
3548
|
this.sendResult(packageName, requestId, true, result)
|
|
3467
3549
|
} catch (err) {
|
|
@@ -3482,6 +3564,133 @@ class LocalMiniappRuntime {
|
|
|
3482
3564
|
return this.handleStreamStop(packageName, payload, requestId)
|
|
3483
3565
|
}
|
|
3484
3566
|
|
|
3567
|
+
private ensureMeetingStateBridge(): void {
|
|
3568
|
+
acsMeetingService.setStateHandler((owner, state) => {
|
|
3569
|
+
this.sendToMiniapp(owner, {
|
|
3570
|
+
type: MiniappResponseType.MEETING_STATE,
|
|
3571
|
+
...state,
|
|
3572
|
+
})
|
|
3573
|
+
})
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
private async handleMeetingJoin(
|
|
3577
|
+
packageName: string,
|
|
3578
|
+
payload: Record<string, unknown>,
|
|
3579
|
+
requestId?: string,
|
|
3580
|
+
): Promise<void> {
|
|
3581
|
+
this.ensureMeetingStateBridge()
|
|
3582
|
+
// A meeting puts the glasses camera and mic in front of remote strangers; it
|
|
3583
|
+
// needs both declared, same as photo/stream and mic capture do individually.
|
|
3584
|
+
if (
|
|
3585
|
+
!this.requireManifestPermission(packageName, requestId, "CAMERA", "to join a meeting", MiniappRequestType.MEETING_JOIN) ||
|
|
3586
|
+
!this.requireManifestPermission(packageName, requestId, "MICROPHONE", "to join a meeting", MiniappRequestType.MEETING_JOIN)
|
|
3587
|
+
) {
|
|
3588
|
+
return
|
|
3589
|
+
}
|
|
3590
|
+
const meetingUrl = typeof payload.meetingUrl === "string" ? payload.meetingUrl : ""
|
|
3591
|
+
const token = typeof payload.token === "string" ? payload.token : ""
|
|
3592
|
+
const videoSource = payload.videoSource as {type?: string; url?: string} | undefined
|
|
3593
|
+
const whepUrl = videoSource?.type === "whep" ? videoSource.url ?? "" : ""
|
|
3594
|
+
const displayName = typeof payload.displayName === "string" ? payload.displayName : undefined
|
|
3595
|
+
if (!meetingUrl || !token || !whepUrl) {
|
|
3596
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3597
|
+
code: MiniappErrorCode.INVALID_ARGUMENT,
|
|
3598
|
+
message: "meetingUrl, token, and a WHEP videoSource are required",
|
|
3599
|
+
})
|
|
3600
|
+
return
|
|
3601
|
+
}
|
|
3602
|
+
try {
|
|
3603
|
+
let video: ReturnType<typeof parseAcsOutgoingVideo>
|
|
3604
|
+
try {
|
|
3605
|
+
video = parseAcsOutgoingVideo(payload.video)
|
|
3606
|
+
} catch (error) {
|
|
3607
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3608
|
+
code: MiniappErrorCode.INVALID_ARGUMENT,
|
|
3609
|
+
message: error instanceof Error ? error.message : "Invalid meeting video",
|
|
3610
|
+
})
|
|
3611
|
+
return
|
|
3612
|
+
}
|
|
3613
|
+
const state = await acsMeetingService.join(packageName, {
|
|
3614
|
+
meetingUrl,
|
|
3615
|
+
token,
|
|
3616
|
+
whepUrl,
|
|
3617
|
+
displayName,
|
|
3618
|
+
...(video ? {video} : {}),
|
|
3619
|
+
})
|
|
3620
|
+
this.sendResult(packageName, requestId, true, state)
|
|
3621
|
+
} catch (err) {
|
|
3622
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3623
|
+
code: MiniappErrorCode.INTERNAL,
|
|
3624
|
+
message: err instanceof Error ? err.message : "ACS meeting join failed",
|
|
3625
|
+
})
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
private async handleMeetingLeave(packageName: string, requestId?: string): Promise<void> {
|
|
3630
|
+
try {
|
|
3631
|
+
await acsMeetingService.leave(packageName)
|
|
3632
|
+
this.sendResult(packageName, requestId, true)
|
|
3633
|
+
} catch (err) {
|
|
3634
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3635
|
+
code: MiniappErrorCode.INTERNAL,
|
|
3636
|
+
message: err instanceof Error ? err.message : "ACS meeting leave failed",
|
|
3637
|
+
})
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
private async handleMeetingSetMuted(
|
|
3642
|
+
packageName: string,
|
|
3643
|
+
payload: Record<string, unknown>,
|
|
3644
|
+
requestId?: string,
|
|
3645
|
+
): Promise<void> {
|
|
3646
|
+
try {
|
|
3647
|
+
const state = await acsMeetingService.setMuted(packageName, Boolean(payload.muted))
|
|
3648
|
+
this.sendResult(packageName, requestId, true, state)
|
|
3649
|
+
} catch (err) {
|
|
3650
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3651
|
+
code: MiniappErrorCode.INTERNAL,
|
|
3652
|
+
message: err instanceof Error ? err.message : "ACS mute failed",
|
|
3653
|
+
})
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
private async handleMeetingUpdateVideoSource(
|
|
3658
|
+
packageName: string,
|
|
3659
|
+
payload: Record<string, unknown>,
|
|
3660
|
+
requestId?: string,
|
|
3661
|
+
): Promise<void> {
|
|
3662
|
+
const videoSource = payload.videoSource as {type?: string; url?: string} | undefined
|
|
3663
|
+
const whepUrl = videoSource?.type === "whep" ? videoSource.url ?? "" : ""
|
|
3664
|
+
if (!whepUrl) {
|
|
3665
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3666
|
+
code: MiniappErrorCode.INVALID_ARGUMENT,
|
|
3667
|
+
message: "videoSource must be a WHEP URL",
|
|
3668
|
+
})
|
|
3669
|
+
return
|
|
3670
|
+
}
|
|
3671
|
+
try {
|
|
3672
|
+
await acsMeetingService.updateVideoSource(packageName, whepUrl)
|
|
3673
|
+
this.sendResult(packageName, requestId, true)
|
|
3674
|
+
} catch (err) {
|
|
3675
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3676
|
+
code: MiniappErrorCode.INTERNAL,
|
|
3677
|
+
message: err instanceof Error ? err.message : "ACS video source update failed",
|
|
3678
|
+
})
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
private async handleMeetingGetState(packageName: string, requestId?: string): Promise<void> {
|
|
3683
|
+
try {
|
|
3684
|
+
const state = await acsMeetingService.readState(packageName)
|
|
3685
|
+
this.sendResult(packageName, requestId, true, state)
|
|
3686
|
+
} catch (err) {
|
|
3687
|
+
this.sendResult(packageName, requestId, false, undefined, {
|
|
3688
|
+
code: MiniappErrorCode.INTERNAL,
|
|
3689
|
+
message: err instanceof Error ? err.message : "ACS getState failed",
|
|
3690
|
+
})
|
|
3691
|
+
}
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3485
3694
|
/**
|
|
3486
3695
|
* session.glasses.requestWifiSetup — open the phone's glasses Wi-Fi setup
|
|
3487
3696
|
* flow. The host owns the actual UI via the `engine.configure({ui})` seam;
|
|
@@ -3904,6 +4113,9 @@ class LocalMiniappRuntime {
|
|
|
3904
4113
|
// The bare `touch_event` stream above still catches `onTouch(handler)`.
|
|
3905
4114
|
let perGestureStream: string | null = null
|
|
3906
4115
|
let outboundData = data
|
|
4116
|
+
if (normalizedStream === MiniappStreamType.GLASSES_CONNECTION) {
|
|
4117
|
+
outboundData = toMiniappConnectionData(data) ?? data
|
|
4118
|
+
}
|
|
3907
4119
|
if (normalizedStream === MiniappStreamType.TOUCH_EVENT) {
|
|
3908
4120
|
// The Bluetooth SDK delivers the gesture under `gestureName` (single_tap /
|
|
3909
4121
|
// double_tap / triple_tap / long_press / swipe_up / swipe_down). Surface it
|
|
@@ -469,7 +469,13 @@ export class MentraJSRouter {
|
|
|
469
469
|
// dead and let the host surface a "tap to retry" banner.
|
|
470
470
|
if (iface === "__error") {
|
|
471
471
|
const payload = this.tryParseArgs(msg.argsJson)
|
|
472
|
-
|
|
472
|
+
// ready_nack is a liveness probe, not a crash. Logging it at error
|
|
473
|
+
// painted WHIP-start queue delay as a Mentra-Call exception.
|
|
474
|
+
if (method === "ready_nack") {
|
|
475
|
+
this.logger.warn(`[${packageName}] ${method}`, payload)
|
|
476
|
+
} else {
|
|
477
|
+
this.logger.error(`[${packageName}] ${method}`, payload)
|
|
478
|
+
}
|
|
473
479
|
if (this.crashController) {
|
|
474
480
|
// Only treat "exception" + "unhandledRejection" + "uncaught" as
|
|
475
481
|
// crash signals. `console.error` calls also flow through the
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import {File} from "expo-file-system"
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import {resolveDevBundleSource} from "../utils/devMiniappSnapshot"
|
|
29
29
|
import {storage} from "../utils/storage/storage"
|
|
30
30
|
import appRegistry, {getLocalAppRunningState, saveLocalAppRunningState} from "./AppRegistry"
|
|
31
31
|
import devServerBridge from "./DevServerBridge"
|
|
@@ -96,67 +96,89 @@ class MiniappLauncher {
|
|
|
96
96
|
* package. Handles both dev (HTTP off the running dev server) and released
|
|
97
97
|
* (file:// from the installed snapshot). Reads disk/network/storage; does
|
|
98
98
|
* NOT spawn. Returns null when the bundle can't be resolved (dev server
|
|
99
|
-
* unreachable, missing entry, no installed version).
|
|
99
|
+
* unreachable with no on-disk snapshot, missing entry, no installed version).
|
|
100
100
|
*/
|
|
101
101
|
async resolveBundle(packageName: string, hints?: LaunchHints): Promise<ResolvedBundle | null> {
|
|
102
102
|
const devUrl = hints?.devUrl ?? this.storedDevUrl(packageName)
|
|
103
103
|
|
|
104
|
-
// --- Dev:
|
|
104
|
+
// --- Dev: live HTTP, then the last on-disk snapshot if the laptop is gone. ---
|
|
105
105
|
if (devUrl) {
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// Use the host that actually answered — may differ from the stored IP
|
|
113
|
-
// after a laptop Wi-Fi change (mDNS / Metro failover inside decideDevLaunchRoute).
|
|
114
|
-
const base = route.resolvedUrl.replace(/\/$/, "")
|
|
115
|
-
// entry.* are bundle-root paths (dist/ stripped); the dev server serves
|
|
116
|
-
// files relative to cwd, so prepend dist/.
|
|
117
|
-
const bgUrl = `${base}/dist/${entry.background.replace(/^\.?\/+/, "")}`
|
|
118
|
-
const uiUri = entry.ui ? `${base}/dist/${entry.ui.replace(/^\.?\/+/, "")}` : null
|
|
119
|
-
|
|
120
|
-
const perms = manifest.permissions as Array<{type?: string} | string> | undefined
|
|
121
|
-
const declaredPermissions = (perms ?? [])
|
|
122
|
-
.map((p) => (typeof p === "string" ? p : p?.type))
|
|
123
|
-
.filter((t): t is string => typeof t === "string")
|
|
124
|
-
const installedManifest: InstalledMiniappManifest = {
|
|
125
|
-
packageName: typeof manifest.packageName === "string" ? manifest.packageName : packageName,
|
|
126
|
-
name: manifest.name,
|
|
127
|
-
version: typeof manifest.version === "string" ? manifest.version : undefined,
|
|
128
|
-
sdkVersion: typeof manifest.sdkVersion === "string" ? manifest.sdkVersion : undefined,
|
|
129
|
-
minHostVersion: typeof manifest.minHostVersion === "string" ? manifest.minHostVersion : undefined,
|
|
130
|
-
type: typeof manifest.type === "string" ? manifest.type : undefined,
|
|
131
|
-
entry: manifest.entry as InstalledMiniappManifest["entry"],
|
|
132
|
-
permissions: manifest.permissions as InstalledMiniappManifest["permissions"],
|
|
133
|
-
hardwareRequirements: manifest.hardwareRequirements as InstalledMiniappManifest["hardwareRequirements"],
|
|
134
|
-
actions: manifest.actions as InstalledMiniappManifest["actions"],
|
|
106
|
+
const source = await resolveDevBundleSource(packageName, devUrl)
|
|
107
|
+
if (source.kind === "live") {
|
|
108
|
+
const live = await this.resolveLiveHttp(packageName, source.resolvedUrl, source.manifest, hints)
|
|
109
|
+
if (live) return live
|
|
110
|
+
// Live probe succeeded but the entry fetch failed — still try disk.
|
|
135
111
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
bgSource = await res.text()
|
|
142
|
-
} catch {
|
|
143
|
-
return null
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
bgSource,
|
|
148
|
-
uiUri,
|
|
149
|
-
uiBaseDir: uiUri ? uiUri.replace(/\/[^/]+$/, "/") : null,
|
|
150
|
-
declaredPermissions,
|
|
151
|
-
installedManifest,
|
|
152
|
-
devUrl: route.resolvedUrl,
|
|
153
|
-
devPort: this.resolveDevPort(hints?.devPort, packageName),
|
|
112
|
+
const snapshotVersion =
|
|
113
|
+
source.kind === "snapshot" ? source.version : appRegistry.getLatestDevSnapshotVersion(packageName)
|
|
114
|
+
if (snapshotVersion) {
|
|
115
|
+
const snapshot = await this.resolveInstalledBundle(packageName, snapshotVersion)
|
|
116
|
+
if (snapshot) return snapshot
|
|
154
117
|
}
|
|
118
|
+
return null
|
|
155
119
|
}
|
|
156
120
|
|
|
157
121
|
// --- Released: resolve from the installed file:// snapshot. ---
|
|
158
122
|
const version = hints?.version ?? (await appRegistry.getActiveVersion(packageName))
|
|
159
123
|
if (!version) return null
|
|
124
|
+
return this.resolveInstalledBundle(packageName, version)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async resolveLiveHttp(
|
|
128
|
+
packageName: string,
|
|
129
|
+
resolvedUrl: string,
|
|
130
|
+
manifest: {entry?: unknown; permissions?: unknown; [key: string]: unknown},
|
|
131
|
+
hints?: LaunchHints,
|
|
132
|
+
): Promise<ResolvedBundle | null> {
|
|
133
|
+
const entry = manifest.entry as {background?: string; ui?: string} | undefined
|
|
134
|
+
if (!entry?.background) return null
|
|
135
|
+
|
|
136
|
+
// Use the host that actually answered — may differ from the stored IP
|
|
137
|
+
// after a laptop Wi-Fi change (mDNS / Metro failover inside decideDevLaunchRoute).
|
|
138
|
+
const base = resolvedUrl.replace(/\/$/, "")
|
|
139
|
+
// entry.* are bundle-root paths (dist/ stripped); the dev server serves
|
|
140
|
+
// files relative to cwd, so prepend dist/.
|
|
141
|
+
const bgUrl = `${base}/dist/${entry.background.replace(/^\.?\/+/, "")}`
|
|
142
|
+
const uiUri = entry.ui ? `${base}/dist/${entry.ui.replace(/^\.?\/+/, "")}` : null
|
|
143
|
+
|
|
144
|
+
const perms = manifest.permissions as Array<{type?: string} | string> | undefined
|
|
145
|
+
const declaredPermissions = (perms ?? [])
|
|
146
|
+
.map((p) => (typeof p === "string" ? p : p?.type))
|
|
147
|
+
.filter((t): t is string => typeof t === "string")
|
|
148
|
+
const installedManifest: InstalledMiniappManifest = {
|
|
149
|
+
packageName: typeof manifest.packageName === "string" ? manifest.packageName : packageName,
|
|
150
|
+
name: typeof manifest.name === "string" ? manifest.name : undefined,
|
|
151
|
+
version: typeof manifest.version === "string" ? manifest.version : undefined,
|
|
152
|
+
sdkVersion: typeof manifest.sdkVersion === "string" ? manifest.sdkVersion : undefined,
|
|
153
|
+
minHostVersion: typeof manifest.minHostVersion === "string" ? manifest.minHostVersion : undefined,
|
|
154
|
+
type: typeof manifest.type === "string" ? manifest.type : undefined,
|
|
155
|
+
entry: manifest.entry as InstalledMiniappManifest["entry"],
|
|
156
|
+
permissions: manifest.permissions as InstalledMiniappManifest["permissions"],
|
|
157
|
+
hardwareRequirements: manifest.hardwareRequirements as InstalledMiniappManifest["hardwareRequirements"],
|
|
158
|
+
actions: manifest.actions as InstalledMiniappManifest["actions"],
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let bgSource: string
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetch(bgUrl)
|
|
164
|
+
if (!res.ok) return null
|
|
165
|
+
bgSource = await res.text()
|
|
166
|
+
} catch {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
bgSource,
|
|
172
|
+
uiUri,
|
|
173
|
+
uiBaseDir: uiUri ? uiUri.replace(/\/[^/]+$/, "/") : null,
|
|
174
|
+
declaredPermissions,
|
|
175
|
+
installedManifest,
|
|
176
|
+
devUrl: resolvedUrl,
|
|
177
|
+
devPort: this.resolveDevPort(hints?.devPort, packageName),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async resolveInstalledBundle(packageName: string, version: string): Promise<ResolvedBundle | null> {
|
|
160
182
|
const entryPaths = appRegistry.getMiniappEntryPaths(packageName, version)
|
|
161
183
|
if (!entryPaths?.background) return null
|
|
162
184
|
|
|
@@ -203,6 +225,7 @@ class MiniappLauncher {
|
|
|
203
225
|
uiBaseDir: entryPaths.ui ? entryPaths.ui.replace(/\/[^/]+$/, "/") : null,
|
|
204
226
|
declaredPermissions,
|
|
205
227
|
installedManifest,
|
|
228
|
+
// Snapshot fallback is file:// — do not wire the sidecar; the laptop is gone.
|
|
206
229
|
devUrl: null,
|
|
207
230
|
devPort: null,
|
|
208
231
|
}
|
|
@@ -90,6 +90,40 @@ export class PhoneCameraFovCoordinator {
|
|
|
90
90
|
})
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Re-assert the current override on the glasses.
|
|
95
|
+
*
|
|
96
|
+
* The on-connect device-settings push replays the persistent `camera_fov`,
|
|
97
|
+
* so glasses that connect mid-call come up at the user's saved value and
|
|
98
|
+
* silently drop a miniapp's override — a Mentra Call wearer loses the 102°
|
|
99
|
+
* bottom crop by connecting their glasses after joining.
|
|
100
|
+
*
|
|
101
|
+
* The effective lease is recomputed inside [enqueue], not captured at call
|
|
102
|
+
* time: a `setOverride` or `releaseForApp` already queued ahead of this must
|
|
103
|
+
* win, and it is this coordinator that has to be the authoritative last
|
|
104
|
+
* writer. Sends the complete override — `fov` *and* `roiPosition` — because
|
|
105
|
+
* the scalar alone leaves the ROI wherever the persistent setting put it.
|
|
106
|
+
*
|
|
107
|
+
* No-op in legacy mode: those commands restart the camera HAL, which is far
|
|
108
|
+
* too expensive to fire on every reconnect.
|
|
109
|
+
*/
|
|
110
|
+
reapplyEffectiveOverride(): Promise<void> {
|
|
111
|
+
return this.enqueue(async () => {
|
|
112
|
+
if (this.legacyMode) return
|
|
113
|
+
const leaseId = this.effectiveLeaseId
|
|
114
|
+
if (!leaseId) return
|
|
115
|
+
const entry = [...this.overrides.values()].find((candidate) => candidate.leaseId === leaseId)
|
|
116
|
+
if (!entry) return
|
|
117
|
+
await BluetoothSdk.setCameraFovOverride({
|
|
118
|
+
leaseId,
|
|
119
|
+
fov: entry.fov,
|
|
120
|
+
roiPosition: entry.roiPosition,
|
|
121
|
+
ttlMs: CAMERA_FOV_OVERRIDE_TTL_MS,
|
|
122
|
+
})
|
|
123
|
+
this.scheduleRefresh()
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
93
127
|
releaseForApp(packageName: string): Promise<void> {
|
|
94
128
|
return this.enqueue(async () => {
|
|
95
129
|
const removed = this.overrides.get(packageName)
|