@mentra/engine 3.2.0-dev.235 → 3.2.0-dev.247
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/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/AcsMeetingService.d.ts +70 -0
- package/build/services/AcsMeetingService.d.ts.map +1 -1
- package/build/services/AcsMeetingService.js +97 -0
- package/build/services/AcsMeetingService.js.map +1 -1
- package/build/services/AppRegistry.d.ts +2 -12
- package/build/services/AppRegistry.d.ts.map +1 -1
- package/build/services/AppRegistry.js +2 -37
- package/build/services/AppRegistry.js.map +1 -1
- package/build/services/CloudClientService.d.ts +1 -1
- package/build/services/CloudClientService.d.ts.map +1 -1
- package/build/services/CloudClientService.js +2 -2
- package/build/services/CloudClientService.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +51 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +228 -6
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/PhoneNotificationsSync.d.ts +1 -1
- package/build/services/PhonePhotoCoordinator.d.ts +3 -2
- package/build/services/PhonePhotoCoordinator.d.ts.map +1 -1
- package/build/services/PhonePhotoCoordinator.js +19 -14
- package/build/services/PhonePhotoCoordinator.js.map +1 -1
- package/build/services/SoftapCallTransport.d.ts +64 -0
- package/build/services/SoftapCallTransport.d.ts.map +1 -1
- package/build/services/SoftapCallTransport.js +103 -3
- package/build/services/SoftapCallTransport.js.map +1 -1
- package/build/services/manifestPermissions.d.ts +13 -0
- package/build/services/manifestPermissions.d.ts.map +1 -0
- package/build/services/manifestPermissions.js +42 -0
- package/build/services/manifestPermissions.js.map +1 -0
- package/build/types/applet.d.ts +13 -1
- package/build/types/applet.d.ts.map +1 -1
- package/build/types/applet.js.map +1 -1
- package/package.json +8 -8
- package/src/generated/releaseMetadata.ts +5 -5
- package/src/services/AcsMeetingService.ts +138 -0
- package/src/services/AppRegistry.ts +3 -40
- package/src/services/CloudClientService.ts +2 -2
- package/src/services/LocalMiniappRuntime.ts +268 -7
- package/src/services/PhonePhotoCoordinator.ts +18 -14
- package/src/services/SoftapCallTransport.ts +151 -16
- package/src/services/manifestPermissions.ts +44 -0
- package/src/types/applet.ts +11 -0
|
@@ -83,6 +83,23 @@ export function parseMeetingCapabilities(raw: unknown): MeetingCapabilities | un
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Read the ACS `CallEndReason` off a native state event.
|
|
88
|
+
*
|
|
89
|
+
* Native flattens it into `endReason_code` / `endReason_subcode` / `endReason_message` rather than
|
|
90
|
+
* a nested object, because the Expo bridge drops nested nulls. Anything non-numeric is discarded:
|
|
91
|
+
* a code that arrived as a string would compare unequal to every entry in the lookup tables and
|
|
92
|
+
* classify a known failure as unknown, which is worse than having no code at all.
|
|
93
|
+
*/
|
|
94
|
+
export function parseMeetingEndReason(event: Record<string, unknown>): MeetingEndReason | undefined {
|
|
95
|
+
const num = (value: unknown): number | undefined => (typeof value === "number" && Number.isFinite(value) ? value : undefined)
|
|
96
|
+
const code = num(event.endReason_code)
|
|
97
|
+
const subcode = num(event.endReason_subcode)
|
|
98
|
+
const message = typeof event.endReason_message === "string" ? event.endReason_message : undefined
|
|
99
|
+
if (code === undefined && subcode === undefined && !message) return undefined
|
|
100
|
+
return {...(code !== undefined ? {code} : {}), ...(subcode !== undefined ? {subcode} : {}), ...(message ? {message} : {})}
|
|
101
|
+
}
|
|
102
|
+
|
|
86
103
|
export interface MeetingState {
|
|
87
104
|
state: MeetingPhase
|
|
88
105
|
muted: boolean
|
|
@@ -107,6 +124,22 @@ export interface MeetingState {
|
|
|
107
124
|
* so a consumer keeps the last one it saw rather than treating its absence as a reset.
|
|
108
125
|
*/
|
|
109
126
|
softap?: SoftapProgress
|
|
127
|
+
/**
|
|
128
|
+
* ACS `CallEndReason`, forwarded numerically.
|
|
129
|
+
*
|
|
130
|
+
* It is the only machine-readable statement of *why* a join failed, and a miniapp cannot tell a
|
|
131
|
+
* dead meeting link from a dropped network any other way — the human message is localised and
|
|
132
|
+
* reworded between SDK releases, so matching on it would turn an outage into "your link is
|
|
133
|
+
* broken" the first time Microsoft rephrases a sentence.
|
|
134
|
+
*/
|
|
135
|
+
endReason?: MeetingEndReason
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Numeric ACS `CallEndReason`. `message` is for logs and bug reports only, never for branching. */
|
|
139
|
+
export interface MeetingEndReason {
|
|
140
|
+
code?: number
|
|
141
|
+
subcode?: number
|
|
142
|
+
message?: string
|
|
110
143
|
}
|
|
111
144
|
|
|
112
145
|
/**
|
|
@@ -291,6 +324,19 @@ export interface DefaultNetworkStatus {
|
|
|
291
324
|
detail: string
|
|
292
325
|
}
|
|
293
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Which path the wearer took into the call.
|
|
329
|
+
*
|
|
330
|
+
* Diagnostic only — nothing behaves differently — but it is carried all the way to the native
|
|
331
|
+
* traces because the open question about video quality is exactly "do these two differ", and a
|
|
332
|
+
* log that cannot separate them cannot answer it.
|
|
333
|
+
*/
|
|
334
|
+
export type AcsCallOrigin = "created" | "joined" | "unknown"
|
|
335
|
+
|
|
336
|
+
export function parseAcsCallOrigin(value: unknown): AcsCallOrigin {
|
|
337
|
+
return value === "created" || value === "joined" ? value : "unknown"
|
|
338
|
+
}
|
|
339
|
+
|
|
294
340
|
type NativeModule = {
|
|
295
341
|
prepareAgent?(options: {token: string; displayName?: string}): Promise<MeetingState>
|
|
296
342
|
join(options: {
|
|
@@ -304,6 +350,7 @@ type NativeModule = {
|
|
|
304
350
|
audioSource?: "glasses" | "phone"
|
|
305
351
|
audioDelayMs?: number
|
|
306
352
|
video?: AcsOutgoingVideo
|
|
353
|
+
origin?: AcsCallOrigin
|
|
307
354
|
}): Promise<MeetingState & {ingestUrl?: string}>
|
|
308
355
|
leave(): Promise<void>
|
|
309
356
|
/**
|
|
@@ -315,6 +362,16 @@ type NativeModule = {
|
|
|
315
362
|
* first one's teardown. Absent on natives that predate the signal.
|
|
316
363
|
*/
|
|
317
364
|
leaveAndAwait?(options: {timeoutMs: number}): Promise<{completed: boolean}>
|
|
365
|
+
/**
|
|
366
|
+
* Wait for the SoftAP WHIP listener's port. `closed: false` means it is still held.
|
|
367
|
+
*
|
|
368
|
+
* Separate from [leaveAndAwait] because the listener outlives the ACS teardown on purpose: it
|
|
369
|
+
* answers `410` for a few seconds so an in-flight request from the glasses gets a status rather
|
|
370
|
+
* than a reset. Absent on natives that predate the barrier.
|
|
371
|
+
*/
|
|
372
|
+
awaitIngestClosed?(options: {timeoutMs: number}): Promise<{closed: boolean}>
|
|
373
|
+
/** Drop the retiring WHIP listener now, skipping its grace period. */
|
|
374
|
+
forceCloseIngest?(): Promise<void>
|
|
318
375
|
/**
|
|
319
376
|
* End the group call for everyone, then tear this device down. Rejects when the capability is
|
|
320
377
|
* denied or ACS refuses — and has still left the call. Absent on natives that predate End.
|
|
@@ -331,6 +388,8 @@ type NativeModule = {
|
|
|
331
388
|
*/
|
|
332
389
|
joinScopedNetwork?(ssid: string, passphrase: string): Promise<string>
|
|
333
390
|
joinScopedNetworkWithGateway?(ssid: string, passphrase: string, gateway: string): Promise<string>
|
|
391
|
+
/** Is this phone's Wi-Fi radio on? Absent on natives that predate the preflight. */
|
|
392
|
+
isWifiEnabled?(): Promise<boolean>
|
|
334
393
|
beginTrace?(traceId: string): Promise<void>
|
|
335
394
|
leaveScopedNetwork?(): Promise<void>
|
|
336
395
|
cancelScopedNetworkJoin?(): Promise<void>
|
|
@@ -527,6 +586,8 @@ class AcsMeetingService {
|
|
|
527
586
|
* wearer's voice at a native session that has already left the meeting.
|
|
528
587
|
*/
|
|
529
588
|
private callGeneration = 0
|
|
589
|
+
/** Stamped on the host-side state traces so they can be grouped the same way the native ones are. */
|
|
590
|
+
private callOrigin: AcsCallOrigin = "unknown"
|
|
530
591
|
private micTransport: MicTransport = "whip"
|
|
531
592
|
private micSub: {remove: () => void} | null = null
|
|
532
593
|
/** True between the pin/requirement being taken and released, so release is exactly once. */
|
|
@@ -576,6 +637,37 @@ class AcsMeetingService {
|
|
|
576
637
|
return this.micTransport === "ble-lc3"
|
|
577
638
|
}
|
|
578
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Has the SoftAP video receiver released its port?
|
|
642
|
+
*
|
|
643
|
+
* A native that cannot answer reports `true`: it also predates the tombstone this waits out, so
|
|
644
|
+
* there is nothing for the barrier to be waiting on.
|
|
645
|
+
*/
|
|
646
|
+
async awaitIngestClosed(timeoutMs: number): Promise<boolean> {
|
|
647
|
+
const native = getNative()
|
|
648
|
+
if (!native?.awaitIngestClosed) return true
|
|
649
|
+
const outcome = await native.awaitIngestClosed({timeoutMs})
|
|
650
|
+
return outcome.closed
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Close the retiring WHIP listener now. No-op on natives without it. */
|
|
654
|
+
async forceCloseIngest(): Promise<void> {
|
|
655
|
+
await getNative()?.forceCloseIngest?.()
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Is this phone's Wi-Fi radio on?
|
|
660
|
+
*
|
|
661
|
+
* `null` means the question cannot be answered on this host — iOS, or a native that predates the
|
|
662
|
+
* preflight — and callers must treat that as "carry on", not as "off". Refusing a call because
|
|
663
|
+
* we could not ask would break every platform that never had the problem.
|
|
664
|
+
*/
|
|
665
|
+
async isWifiEnabled(): Promise<boolean | null> {
|
|
666
|
+
const native = getNative()
|
|
667
|
+
if (!native?.isWifiEnabled) return null
|
|
668
|
+
return await native.isWifiEnabled()
|
|
669
|
+
}
|
|
670
|
+
|
|
579
671
|
/**
|
|
580
672
|
* Join the glasses hotspot as a scoped, internet-less network, returning this phone's address on
|
|
581
673
|
* it. Called before the ACS join, because the local WHIP listener has to bind to that address.
|
|
@@ -786,6 +878,7 @@ class AcsMeetingService {
|
|
|
786
878
|
videoSource: AcsVideoSource
|
|
787
879
|
displayName?: string
|
|
788
880
|
video?: AcsOutgoingVideo
|
|
881
|
+
origin?: AcsCallOrigin
|
|
789
882
|
},
|
|
790
883
|
): Promise<MeetingState> {
|
|
791
884
|
const native = getNative()
|
|
@@ -826,6 +919,8 @@ class AcsMeetingService {
|
|
|
826
919
|
preferredMic: useSettingsStore.getState().getSetting(SETTINGS.preferred_mic.key),
|
|
827
920
|
})
|
|
828
921
|
const joinStartedAt = Date.now()
|
|
922
|
+
const origin = parseAcsCallOrigin(args.origin)
|
|
923
|
+
this.callOrigin = origin
|
|
829
924
|
softapTrace("acs_native_join", {
|
|
830
925
|
packageName,
|
|
831
926
|
generation,
|
|
@@ -834,6 +929,18 @@ class AcsMeetingService {
|
|
|
834
929
|
micTransport: this.micTransport,
|
|
835
930
|
audioDelayMs: lc3Uplink ? SOFTAP_LC3_AUDIO_DELAY_MS : 0,
|
|
836
931
|
video: video ? `${video.width}x${video.height}@${video.fps}` : "default",
|
|
932
|
+
origin,
|
|
933
|
+
})
|
|
934
|
+
// Duplicated from the native `native_join_options` line on purpose: if the native trace is
|
|
935
|
+
// missing from a capture — a bug report with only the JS log, a crash before export — this is
|
|
936
|
+
// the only record of what the two paths actually asked for.
|
|
937
|
+
softapTrace("acs_join_options", {
|
|
938
|
+
origin,
|
|
939
|
+
transport: args.videoSource.type,
|
|
940
|
+
width: video?.width ?? 0,
|
|
941
|
+
height: video?.height ?? 0,
|
|
942
|
+
fps: video?.fps ?? 0,
|
|
943
|
+
maxBitrateBps: video?.maxBitrateBps ?? 0,
|
|
837
944
|
})
|
|
838
945
|
try {
|
|
839
946
|
const state = await native.join({
|
|
@@ -843,6 +950,7 @@ class AcsMeetingService {
|
|
|
843
950
|
videoSource: args.videoSource,
|
|
844
951
|
displayName: args.displayName,
|
|
845
952
|
audioSource: resolved.source,
|
|
953
|
+
origin,
|
|
846
954
|
...(lc3Uplink ? {audioDelayMs: SOFTAP_LC3_AUDIO_DELAY_MS} : {}),
|
|
847
955
|
...(video ? {video} : {}),
|
|
848
956
|
})
|
|
@@ -1315,8 +1423,22 @@ class AcsMeetingService {
|
|
|
1315
1423
|
|
|
1316
1424
|
private bindNative(native: NativeModule, packageName: string): void {
|
|
1317
1425
|
this.unbindNative()
|
|
1426
|
+
// Captured at bind, checked on every event. `unbindNative` removes the listener, but an event
|
|
1427
|
+
// already dispatched onto the JS queue still runs — and this is the one path where that lands
|
|
1428
|
+
// as the *previous* call's `disconnected` ending the call the wearer just started. Every other
|
|
1429
|
+
// callback that can outlive a session (mic PCM, the in-flight join) is fenced the same way.
|
|
1430
|
+
const generation = this.callGeneration
|
|
1318
1431
|
this.subscriptions = [
|
|
1319
1432
|
native.addListener("onState", (event) => {
|
|
1433
|
+
if (generation !== this.callGeneration) {
|
|
1434
|
+
softapTraceFailure("softap_stale_callback", {
|
|
1435
|
+
source: "acs_state",
|
|
1436
|
+
generation,
|
|
1437
|
+
current: this.callGeneration,
|
|
1438
|
+
state: String(event.state ?? "unknown"),
|
|
1439
|
+
})
|
|
1440
|
+
return
|
|
1441
|
+
}
|
|
1320
1442
|
const audioSafety = parseAudioSafety(event.audioSafety)
|
|
1321
1443
|
if (audioSafety === "unsafe") {
|
|
1322
1444
|
console.error("[AcsMeeting] phase=audio-unsafe", {
|
|
@@ -1332,6 +1454,7 @@ class AcsMeetingService {
|
|
|
1332
1454
|
? {code: event.endReason_code as number, subcode: event.endReason_subcode as number}
|
|
1333
1455
|
: undefined
|
|
1334
1456
|
const capabilities = parseMeetingCapabilities(event.capabilities)
|
|
1457
|
+
const endReason = parseMeetingEndReason(event as Record<string, unknown>)
|
|
1335
1458
|
const state: MeetingState = {
|
|
1336
1459
|
state: (event.state as MeetingPhase) ?? "idle",
|
|
1337
1460
|
muted: Boolean(event.muted),
|
|
@@ -1347,10 +1470,24 @@ class AcsMeetingService {
|
|
|
1347
1470
|
...(mediaSourceReason ? {mediaSourceReason} : {}),
|
|
1348
1471
|
...(callEndReason ? {callEndReason} : {}),
|
|
1349
1472
|
...(participants ? {participants} : {}),
|
|
1473
|
+
...(endReason ? {endReason} : {}),
|
|
1350
1474
|
// Absent means unknown, so keep the last known verdict rather than clearing it.
|
|
1351
1475
|
...((capabilities ?? this.lastState.capabilities) ? {capabilities: capabilities ?? this.lastState.capabilities} : {}),
|
|
1352
1476
|
}
|
|
1477
|
+
const previous = this.lastState.state
|
|
1353
1478
|
this.lastState = state
|
|
1479
|
+
if (state.state !== previous) {
|
|
1480
|
+
// The host's own copy of the transition. Native already traces `acs_call_state` with
|
|
1481
|
+
// timings; this one survives a capture where the native trace is absent, and is the
|
|
1482
|
+
// line the miniapp's timeline is reconciled against.
|
|
1483
|
+
softapTrace("acs_state", {
|
|
1484
|
+
state: state.state,
|
|
1485
|
+
previous,
|
|
1486
|
+
origin: this.callOrigin,
|
|
1487
|
+
mediaSource: state.mediaSource ?? "unknown",
|
|
1488
|
+
micTransport: state.micTransport ?? "unknown",
|
|
1489
|
+
})
|
|
1490
|
+
}
|
|
1354
1491
|
console.log("[AcsMeeting] phase=native-state", {
|
|
1355
1492
|
state: state.state,
|
|
1356
1493
|
muted: state.muted,
|
|
@@ -1363,6 +1500,7 @@ class AcsMeetingService {
|
|
|
1363
1500
|
callEndReason: state.callEndReason,
|
|
1364
1501
|
micTransport: state.micTransport,
|
|
1365
1502
|
participants: participants?.length,
|
|
1503
|
+
endReason: endReason ? `${endReason.code ?? "?"}/${endReason.subcode ?? "?"}` : undefined,
|
|
1366
1504
|
})
|
|
1367
1505
|
this.settleFirstFrameWaiters(mediaSource)
|
|
1368
1506
|
this.onState?.(packageName, state)
|
|
@@ -23,7 +23,7 @@ import {unzip} from "react-native-zip-archive"
|
|
|
23
23
|
import semver from "semver"
|
|
24
24
|
import {AsyncResult, Result, result as Res} from "typesafe-ts"
|
|
25
25
|
|
|
26
|
-
import type {
|
|
26
|
+
import type {AppletType, ClientApp} from "../types/applet"
|
|
27
27
|
import {HardwareRequirement, HardwareRequirementLevel, HardwareType} from "../types"
|
|
28
28
|
import {configuredDevHost} from "../utils/configuredDevHost"
|
|
29
29
|
import {storage} from "../utils/storage/storage"
|
|
@@ -31,10 +31,12 @@ import {printDirectory} from "../utils/storage/zip"
|
|
|
31
31
|
import {isInstalledMiniappAllowed, isOfflineSystemMiniappAllowed} from "../runtime/bootstrap"
|
|
32
32
|
import {checkManifestVersions} from "./manifestVersionGate"
|
|
33
33
|
import {normalizeManifestActions} from "./manifestActions"
|
|
34
|
+
import {normalizeManifestPermissions} from "./manifestPermissions"
|
|
34
35
|
import {miniappInstallIdentityError, type MiniappInstallExpectations} from "./miniappInstallIdentity"
|
|
35
36
|
import {miniappRunningRegistry} from "./MiniappRunningRegistry"
|
|
36
37
|
|
|
37
38
|
export {normalizeManifestActions} from "./manifestActions"
|
|
39
|
+
export {normalizeManifestPermissions} from "./manifestPermissions"
|
|
38
40
|
|
|
39
41
|
let installQueue: Promise<void> = Promise.resolve()
|
|
40
42
|
|
|
@@ -47,45 +49,6 @@ function serializeInstall<T>(operation: () => Promise<T>): Promise<T> {
|
|
|
47
49
|
return result
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
const ALLOWED_PERMISSION_TYPES: ReadonlySet<AppPermissionType> = new Set<AppPermissionType>([
|
|
51
|
-
"MICROPHONE",
|
|
52
|
-
"CAMERA",
|
|
53
|
-
"CALENDAR",
|
|
54
|
-
"LOCATION",
|
|
55
|
-
"BACKGROUND_LOCATION",
|
|
56
|
-
"READ_NOTIFICATIONS",
|
|
57
|
-
"POST_NOTIFICATIONS",
|
|
58
|
-
])
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Normalize the `permissions` field from a miniapp.json manifest.
|
|
62
|
-
*
|
|
63
|
-
* New miniapps ship `[{type, required?, description?}]` objects. A few older
|
|
64
|
-
* installed bundles may have `["MICROPHONE", ...]` plain strings. Accept both.
|
|
65
|
-
*/
|
|
66
|
-
export function normalizeManifestPermissions(
|
|
67
|
-
raw: Array<string | {type: string; required?: boolean; description?: string}> | undefined,
|
|
68
|
-
): AppletPermission[] {
|
|
69
|
-
if (!Array.isArray(raw)) return []
|
|
70
|
-
const out: AppletPermission[] = []
|
|
71
|
-
for (const p of raw) {
|
|
72
|
-
if (typeof p === "string") {
|
|
73
|
-
if (ALLOWED_PERMISSION_TYPES.has(p as AppPermissionType)) {
|
|
74
|
-
out.push({type: p as AppPermissionType, required: true})
|
|
75
|
-
}
|
|
76
|
-
} else if (p && typeof p === "object" && typeof p.type === "string") {
|
|
77
|
-
if (ALLOWED_PERMISSION_TYPES.has(p.type as AppPermissionType)) {
|
|
78
|
-
out.push({
|
|
79
|
-
type: p.type as AppPermissionType,
|
|
80
|
-
...(typeof p.required === "boolean" ? {required: p.required} : {}),
|
|
81
|
-
...(typeof p.description === "string" ? {description: p.description} : {}),
|
|
82
|
-
})
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return out
|
|
87
|
-
}
|
|
88
|
-
|
|
89
52
|
function normalizeManifestType(raw: unknown): AppletType {
|
|
90
53
|
return raw === "background" || raw === "system_dashboard" || raw === "standard" ? raw : "standard"
|
|
91
54
|
}
|
|
@@ -645,9 +645,9 @@ export const cloudClientService = {
|
|
|
645
645
|
},
|
|
646
646
|
|
|
647
647
|
/** Device-side managed photo (cloud-v2): presign now, deliver bytes, await ready. */
|
|
648
|
-
startManagedPhoto(
|
|
648
|
+
startManagedPhoto() {
|
|
649
649
|
if (!client) throw new Error("cloud client not connected")
|
|
650
|
-
return client.runtime.startManagedPhoto(
|
|
650
|
+
return client.runtime.startManagedPhoto()
|
|
651
651
|
},
|
|
652
652
|
awaitManagedPhotoReady(requestId: string) {
|
|
653
653
|
if (!client) throw new Error("cloud client not connected")
|