@mentra/engine 3.4.0-dev.1207 → 3.4.0-dev.1209

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 (67) hide show
  1. package/build/facades/glasses.d.ts.map +1 -1
  2. package/build/facades/glasses.js +5 -3
  3. package/build/facades/glasses.js.map +1 -1
  4. package/build/generated/releaseMetadata.js +5 -5
  5. package/build/generated/releaseMetadata.js.map +1 -1
  6. package/build/services/AppRegistry.d.ts +2 -0
  7. package/build/services/AppRegistry.d.ts.map +1 -1
  8. package/build/services/AppRegistry.js.map +1 -1
  9. package/build/services/LocalMiniappRuntime.d.ts +2 -0
  10. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  11. package/build/services/LocalMiniappRuntime.js +18 -1
  12. package/build/services/LocalMiniappRuntime.js.map +1 -1
  13. package/build/services/MentraJSCrashController.d.ts +2 -0
  14. package/build/services/MentraJSCrashController.d.ts.map +1 -1
  15. package/build/services/MentraJSCrashController.js +4 -0
  16. package/build/services/MentraJSCrashController.js.map +1 -1
  17. package/build/services/MentraJSRouter.d.ts.map +1 -1
  18. package/build/services/MentraJSRouter.js +14 -3
  19. package/build/services/MentraJSRouter.js.map +1 -1
  20. package/build/services/MiniappLauncher.d.ts +6 -0
  21. package/build/services/MiniappLauncher.d.ts.map +1 -1
  22. package/build/services/MiniappLauncher.js +20 -3
  23. package/build/services/MiniappLauncher.js.map +1 -1
  24. package/build/services/fleet/FleetReporter.d.ts.map +1 -1
  25. package/build/services/fleet/FleetReporter.js +43 -0
  26. package/build/services/fleet/FleetReporter.js.map +1 -1
  27. package/build/services/fleet/fleetCollector.d.ts +1 -1
  28. package/build/services/fleet/fleetCollector.d.ts.map +1 -1
  29. package/build/services/fleet/fleetCollector.js +146 -5
  30. package/build/services/fleet/fleetCollector.js.map +1 -1
  31. package/build/services/fleet/fleetEvents.d.ts +39 -3
  32. package/build/services/fleet/fleetEvents.d.ts.map +1 -1
  33. package/build/services/fleet/fleetEvents.js +66 -22
  34. package/build/services/fleet/fleetEvents.js.map +1 -1
  35. package/build/services/fleet/fleetQueue.d.ts +26 -0
  36. package/build/services/fleet/fleetQueue.d.ts.map +1 -1
  37. package/build/services/fleet/fleetQueue.js +94 -8
  38. package/build/services/fleet/fleetQueue.js.map +1 -1
  39. package/build/services/fleet/telemetryLimits.d.ts +16 -0
  40. package/build/services/fleet/telemetryLimits.d.ts.map +1 -0
  41. package/build/services/fleet/telemetryLimits.js +69 -0
  42. package/build/services/fleet/telemetryLimits.js.map +1 -0
  43. package/build/services/installMiniapp.d.ts +13 -0
  44. package/build/services/installMiniapp.d.ts.map +1 -1
  45. package/build/services/installMiniapp.js +64 -1
  46. package/build/services/installMiniapp.js.map +1 -1
  47. package/build/services/miniappReleaseInstall.js +1 -1
  48. package/build/services/miniappReleaseInstall.js.map +1 -1
  49. package/build/stores/apps.d.ts.map +1 -1
  50. package/build/stores/apps.js +16 -1
  51. package/build/stores/apps.js.map +1 -1
  52. package/package.json +8 -8
  53. package/src/facades/glasses.ts +5 -3
  54. package/src/generated/releaseMetadata.ts +5 -5
  55. package/src/services/AppRegistry.ts +2 -0
  56. package/src/services/LocalMiniappRuntime.ts +20 -1
  57. package/src/services/MentraJSCrashController.ts +5 -0
  58. package/src/services/MentraJSRouter.ts +15 -3
  59. package/src/services/MiniappLauncher.ts +18 -2
  60. package/src/services/fleet/FleetReporter.ts +40 -0
  61. package/src/services/fleet/fleetCollector.ts +160 -6
  62. package/src/services/fleet/fleetEvents.ts +104 -20
  63. package/src/services/fleet/fleetQueue.ts +100 -7
  64. package/src/services/fleet/telemetryLimits.ts +78 -0
  65. package/src/services/installMiniapp.ts +78 -2
  66. package/src/services/miniappReleaseInstall.ts +1 -1
  67. package/src/stores/apps.ts +19 -2
@@ -217,6 +217,8 @@ export function createFleetReporter(deps: FleetReporterDeps = {}): FleetReporter
217
217
  clearPartition: (partition) => queue.clearPartition(partition),
218
218
  size: (partition) => queue.size(partition),
219
219
  nextSequence: (partition, collectorSessionId) => queue.nextSequence(partition, collectorSessionId),
220
+ noteTelemetryLoss: (partition, packageName, kind, count, at) => queue.noteTelemetryLoss(partition, packageName, kind, count, at),
221
+ takeTelemetryLosses: (partition, at) => queue.takeTelemetryLosses(partition, at),
220
222
  }
221
223
  const collector = (deps.createCollector ?? ((q: FleetQueue) => createFleetCollector({queue: q})))(collectorQueue)
222
224
 
@@ -429,6 +431,7 @@ export function createFleetReporter(deps: FleetReporterDeps = {}): FleetReporter
429
431
  const at = now()
430
432
  // takeLosses clears the counts it returns, so their gap is queued before anything else can happen.
431
433
  recordLosses(r, queue.takeLosses(r.partition, at), at)
434
+ recordTelemetryLosses(r, at)
432
435
  const records = queue.nextBatch(r.partition, r.batchLimit, MAX_BATCH_BYTES)
433
436
  if (records.length === 0) return ASLEEP
434
437
  let outcome: FleetSendOutcome
@@ -685,9 +688,46 @@ export function createFleetReporter(deps: FleetReporterDeps = {}): FleetReporter
685
688
  }
686
689
  }
687
690
 
691
+ /**
692
+ * The miniapp telemetry lost since the last upload, per package, as one `telemetry_loss` record. Telemetry losses
693
+ * never become coverage gaps. Counts that cannot be queued are lost with the events they counted.
694
+ */
695
+ function recordTelemetryLosses(r: Run, at: number): void {
696
+ const losses = queue.takeTelemetryLosses(r.partition, at)
697
+ if (losses === null) return
698
+ const entries = Object.entries(losses.packages)
699
+ .map(([packageName, counts]) => ({packageName, ...counts}))
700
+ .filter((loss) => loss.overLimit + loss.evicted + loss.expired + loss.rejected > 0)
701
+ .slice(0, FLEET_LIMITS.maxTelemetryLossPackages)
702
+ if (entries.length === 0) return
703
+ const end = iso(at)
704
+ const from = losses.from !== null && timeOf(losses.from) !== null && timeOf(losses.from)! <= at ? losses.from : end
705
+ try {
706
+ queue.enqueue(r.partition, {
707
+ schemaVersion: FLEET_SCHEMA_VERSION,
708
+ recordId: newId(),
709
+ phoneDeviceId: phoneDeviceId(),
710
+ collectorSessionId: r.collectorSessionId,
711
+ sequence: queue.nextSequence(r.partition, r.collectorSessionId),
712
+ observedAt: end,
713
+ capabilities: ["miniapp_telemetry"],
714
+ kind: "telemetry_loss",
715
+ payload: {from, to: end, losses: entries},
716
+ })
717
+ } catch (error) {
718
+ warn("recording telemetry losses", error)
719
+ }
720
+ }
721
+
688
722
  function recordRejected(r: Run, record: FleetRecord, at: number): void {
689
723
  // A refused gap gets no gap of its own, so a rejection can never feed itself.
690
724
  if (record.kind === "coverage_gap") return
725
+ // Rejected telemetry is counted for its miniapp, never as a gap; a refused loss report is not counted again.
726
+ if (record.kind === "telemetry_event") {
727
+ queue.noteTelemetryLoss(r.partition, record.payload.packageName, "rejected", 1, at)
728
+ return
729
+ }
730
+ if (record.kind === "telemetry_loss") return
691
731
  const time = iso(timeOf(record.observedAt) ?? at)
692
732
  enqueueGap(r, {reason: "record_rejected", from: time, to: time, droppedRecordCount: 1}, at)
693
733
  }
@@ -72,9 +72,18 @@ import {useGlassesStore} from "../../stores/glasses"
72
72
  import {BgTimer} from "../../utils/timers"
73
73
  import {miniappRunningRegistry} from "../MiniappRunningRegistry"
74
74
  import {nativeMiniappExecution} from "../NativeMiniappExecution"
75
- import {fleetEvents, type FleetLaunchEvent, type FleetPhotoEvent} from "./fleetEvents"
75
+ import {
76
+ fleetEvents,
77
+ type FleetConnectionEvent,
78
+ type FleetCrashEvent,
79
+ type FleetInstallEvent,
80
+ type FleetLaunchEvent,
81
+ type FleetPhotoEvent,
82
+ type FleetTelemetryEvent,
83
+ } from "./fleetEvents"
76
84
  import {newId, phoneDeviceId} from "./fleetIds"
77
- import type {FleetQueue} from "./fleetQueue"
85
+ import {TELEMETRY_RECORD_PREFIX, type FleetQueue} from "./fleetQueue"
86
+ import {limitEvent, TelemetryRate} from "./telemetryLimits"
78
87
  import {appPhoneBattery} from "./phoneBatterySource"
79
88
  import {
80
89
  glassesProduct,
@@ -208,6 +217,9 @@ const MODELS_WITHOUT_BATTERY: ReadonlySet<string> = new Set<string>()
208
217
  * What the phone measures without glasses. With glasses connected it measures all of them, less the battery of glasses
209
218
  * that cannot report one. A phone that cannot report its own battery does not claim `phone_battery`.
210
219
  */
220
+ /** A disconnect this soon after the person asked for one is theirs. */
221
+ const USER_DISCONNECT_WINDOW_MS = 10_000
222
+
211
223
  const PHONE_CAPABILITIES: Capability[] = [
212
224
  "glasses_connection",
213
225
  "miniapp_execution",
@@ -215,6 +227,10 @@ const PHONE_CAPABILITIES: Capability[] = [
215
227
  "engine_active",
216
228
  "photo_operations",
217
229
  "phone_battery",
230
+ // Phase 12: what this Mentra App measures whatever is connected.
231
+ "miniapp_failures",
232
+ "glasses_connectivity",
233
+ "miniapp_telemetry",
218
234
  ]
219
235
 
220
236
  function warn(what: string, error: unknown): void {
@@ -592,6 +608,12 @@ class CollectorRun {
592
608
  private lastSnapshotKey: string | null = null
593
609
 
594
610
  private known: KnownGlasses | null = null
611
+ /** The store's last connection state, and when the glasses last dropped (for a reconnect's time). */
612
+ private rawConnection: string | null = null
613
+ private disconnectedAt: number | null = null
614
+ private connectFailed = false
615
+ /** Each miniapp's telemetry rate on this phone, for this run. */
616
+ private readonly telemetryRate = new TelemetryRate()
595
617
  private heldPhotos: HeldPhoto[] = []
596
618
  private battery: BatteryReading | null = null
597
619
  private phoneBattery: PhoneBatteryHeld | null = null
@@ -624,6 +646,10 @@ class CollectorRun {
624
646
  fleetEvents.subscribe({
625
647
  launch: (event) => this.guard("launch", () => this.onLaunch(event)),
626
648
  photo: (event) => this.guard("photo", () => this.onPhoto(event)),
649
+ telemetry: (event) => this.guard("telemetry", () => this.onTelemetry(event)),
650
+ crash: (event) => this.guard("crash", () => this.onCrash(event)),
651
+ install: (event) => this.guard("install", () => this.onInstall(event)),
652
+ connection: (event) => this.guard("connection", () => this.onConnection(event)),
627
653
  }),
628
654
  )
629
655
  const appState = AppState.addEventListener("change", (state) =>
@@ -712,10 +738,42 @@ class CollectorRun {
712
738
  this.evaluateSnapshotTriggers()
713
739
  }
714
740
 
741
+ /**
742
+ * Phase 12's connection events, from the store's own states: connected to disconnected is a disconnect (the
743
+ * person's own within 10 s of their asking, otherwise a lost link); connecting or bonding to disconnected without
744
+ * connecting is a failed connection, once until the glasses connect again and never while a lost link waits to come
745
+ * back; connected again after a disconnect is a reconnect, with the time it took.
746
+ * Simulated glasses have none.
747
+ */
748
+ private noteConnectionChange(raw: string, simulated: boolean, wall: number): void {
749
+ const previous = this.rawConnection
750
+ this.rawConnection = raw
751
+ if (previous === null || previous === raw || simulated) return
752
+ if (raw === "disconnected" && previous === "connected") {
753
+ const theirs = wall - fleetEvents.lastUserDisconnect() < USER_DISCONNECT_WINDOW_MS
754
+ this.onConnection({event: "disconnected", category: theirs ? "user_action" : "link_lost"})
755
+ this.disconnectedAt = theirs ? null : wall
756
+ } else if (raw === "disconnected" && (previous === "connecting" || previous === "bonding")) {
757
+ // Glasses away retry all the time: a lost link waiting to come back is its disconnect, and only the first failed
758
+ // connection between two connected spells is one, so an hour of retries is not an hour of records.
759
+ if (this.disconnectedAt === null && !this.connectFailed) {
760
+ this.connectFailed = true
761
+ this.onConnection({event: "connect_failed", category: "unknown"})
762
+ }
763
+ } else if (raw === "connected") {
764
+ this.connectFailed = false
765
+ if (this.disconnectedAt !== null) {
766
+ this.onConnection({event: "reconnected", category: "unknown", reconnectMs: wall - this.disconnectedAt})
767
+ this.disconnectedAt = null
768
+ }
769
+ }
770
+ }
771
+
715
772
  /** Notes what the glasses store says now: the connection, the glasses' identity and versions, and the battery. */
716
773
  private trackGlasses(wall: number): void {
717
774
  const s = useGlassesStore.getState()
718
775
  const state = mapConnection(s.connection.state)
776
+ this.noteConnectionChange(s.connection.state, isSimulatedGlasses(s.deviceModel), wall)
719
777
  if (state !== this.connection.state) {
720
778
  // Glasses that start connecting are not assumed to be the pair that was last here: a new connection is
721
779
  // identified afresh, from what the glasses say, before anything is attributed to a device. Only a new
@@ -1402,6 +1460,102 @@ class CollectorRun {
1402
1460
  ...(versionName ? {versionName} : {}),
1403
1461
  start: event.start,
1404
1462
  outcome: event.outcome,
1463
+ ...(event.outcome === "failed" && event.failureReason ? {failureReason: event.failureReason} : {}),
1464
+ },
1465
+ }))
1466
+ }
1467
+
1468
+ // --- Phase 12: a miniapp's own events, crashes, installs and connection changes -------------------------------
1469
+
1470
+ /** The open execution session of `packageName`, which a telemetry event or crash is attributed to. */
1471
+ private executionOf(packageName: string): string | undefined {
1472
+ return this.sessions.get(`miniapp_execution:${packageName}`)?.id
1473
+ }
1474
+
1475
+ private onTelemetry(event: FleetTelemetryEvent): void {
1476
+ const packageName = text(event.packageName)
1477
+ if (!packageName) return
1478
+ const at = this.env.now()
1479
+ const limited = limitEvent(event.event, event.properties)
1480
+ if (limited === null || !this.telemetryRate.admit(packageName, at)) {
1481
+ this.env.queue.noteTelemetryLoss(this.partition, packageName, "overLimit", 1, at)
1482
+ return
1483
+ }
1484
+ const versionName = text(event.versionName)
1485
+ const executionSessionId = this.executionOf(packageName)
1486
+ this.emit(
1487
+ at,
1488
+ (base) => ({
1489
+ ...base,
1490
+ kind: "telemetry_event",
1491
+ payload: {
1492
+ packageName,
1493
+ ...(versionName ? {versionName} : {}),
1494
+ ...(executionSessionId ? {executionSessionId} : {}),
1495
+ event: limited.event,
1496
+ properties: limited.properties,
1497
+ droppedProperties: limited.droppedProperties,
1498
+ cutValues: limited.cutValues,
1499
+ },
1500
+ }),
1501
+ TELEMETRY_RECORD_PREFIX,
1502
+ )
1503
+ }
1504
+
1505
+ private onCrash(event: FleetCrashEvent): void {
1506
+ const packageName = text(event.packageName)
1507
+ if (!packageName) return
1508
+ const versionName = text(event.versionName)
1509
+ const executionSessionId = this.executionOf(packageName)
1510
+ this.emit(this.env.now(), (base) => ({
1511
+ ...base,
1512
+ kind: "miniapp_crash",
1513
+ payload: {
1514
+ packageName,
1515
+ ...(versionName ? {versionName} : {}),
1516
+ ...(executionSessionId ? {executionSessionId} : {}),
1517
+ surface: event.surface,
1518
+ category: event.category,
1519
+ outcome: event.outcome,
1520
+ crashesInWindow: Math.min(FLEET_LIMITS.maxCrashesInWindow, Math.max(1, Math.round(event.crashesInWindow))),
1521
+ },
1522
+ }))
1523
+ }
1524
+
1525
+ private onInstall(event: FleetInstallEvent): void {
1526
+ const packageName = text(event.packageName)
1527
+ if (!packageName) return
1528
+ const fromVersion = text(event.fromVersion)
1529
+ const toVersion = text(event.toVersion)
1530
+ this.emit(this.env.now(), (base) => ({
1531
+ ...base,
1532
+ kind: "miniapp_install",
1533
+ payload: {
1534
+ packageName,
1535
+ operation: event.operation,
1536
+ source: event.source,
1537
+ ...(fromVersion ? {fromVersion} : {}),
1538
+ ...(toVersion ? {toVersion} : {}),
1539
+ outcome: event.outcome,
1540
+ ...(event.outcome === "succeeded" ? {} : {reason: event.reason ?? "unknown"}),
1541
+ },
1542
+ }))
1543
+ }
1544
+
1545
+ private onConnection(event: FleetConnectionEvent): void {
1546
+ const binding = this.currentBinding() ?? this.known?.binding ?? null
1547
+ const glassesModel = this.known?.model
1548
+ const appVersion = phoneInfo()?.appVersion ?? "unknown"
1549
+ this.emit(this.env.now(), (base) => ({
1550
+ ...base,
1551
+ kind: "connection_event",
1552
+ ...(binding ? {deviceBinding: binding} : {}),
1553
+ payload: {
1554
+ event: event.event,
1555
+ category: event.category,
1556
+ ...(glassesModel ? {glassesModel} : {}),
1557
+ appVersion,
1558
+ ...(event.event === "reconnected" ? {reconnectMs: Math.max(0, Math.round(event.reconnectMs ?? 0))} : {}),
1405
1559
  },
1406
1560
  }))
1407
1561
  }
@@ -1451,10 +1605,10 @@ class CollectorRun {
1451
1605
  return [...measured]
1452
1606
  }
1453
1607
 
1454
- private base(observedAt: number): RecordBase {
1608
+ private base(observedAt: number, idPrefix = ""): RecordBase {
1455
1609
  return {
1456
1610
  schemaVersion: FLEET_SCHEMA_VERSION,
1457
- recordId: newId(),
1611
+ recordId: `${idPrefix}${newId()}`,
1458
1612
  phoneDeviceId: this.phoneId,
1459
1613
  collectorSessionId: this.collectorSessionId,
1460
1614
  sequence: this.env.queue.nextSequence(this.partition, this.collectorSessionId),
@@ -1464,9 +1618,9 @@ class CollectorRun {
1464
1618
  }
1465
1619
 
1466
1620
  /** Builds a record and queues it in this run's partition. A record that cannot be built or stored is skipped. */
1467
- private emit(observedAt: number, make: (base: RecordBase) => FleetRecord): void {
1621
+ private emit(observedAt: number, make: (base: RecordBase) => FleetRecord, idPrefix = ""): void {
1468
1622
  try {
1469
- this.env.queue.enqueue(this.partition, make(this.base(observedAt)))
1623
+ this.env.queue.enqueue(this.partition, make(this.base(observedAt, idPrefix)))
1470
1624
  } catch (error) {
1471
1625
  warn("recording", error)
1472
1626
  }
@@ -15,9 +15,20 @@
15
15
  * the engine restores miniapps while the account it collects for may still be being resolved, and those launches are
16
16
  * that account's. The reporter discards them when the account changes. Photo stages are not kept.
17
17
  *
18
+ * Phase 12 adds a miniapp's own events (`telemetry`, from `session.telemetry.capture`, attributed by the runtime to the
19
+ * package whose bridge sent it), its crashes, install attempts, and the glasses' pairing and connection changes. None of
20
+ * these is kept while nothing listens: with no run collecting (Fleet not installed on the phone's Core, nobody signed
21
+ * in) they are dropped, and telemetry dropped this way is not counted.
22
+ *
18
23
  * This module imports nothing at run time, so the hot paths that import it carry no Fleet weight.
19
24
  */
20
- import type {FleetRecord} from "@mentra/cloud-protocol"
25
+ import type {
26
+ FleetConnectionEventPayload,
27
+ FleetLaunchFailureReason,
28
+ FleetMiniappCrashPayload,
29
+ FleetMiniappInstallPayload,
30
+ FleetRecord,
31
+ } from "@mentra/cloud-protocol"
21
32
 
22
33
  /** A miniapp start the engine attempted, and how it went. */
23
34
  export type FleetLaunchEvent = Extract<FleetRecord, {kind: "miniapp_launch"}>["payload"]
@@ -34,18 +45,42 @@ export interface UndeliveredLaunch {
34
45
  observedAt: number
35
46
  }
36
47
 
48
+ /** A miniapp's own event as the bridge delivered it: unchecked, the collector applies the limits. */
49
+ export interface FleetTelemetryEvent {
50
+ packageName: string
51
+ versionName?: string
52
+ event: unknown
53
+ properties: unknown
54
+ }
55
+
56
+ /** A crash: the collector adds the miniapp's execution session. */
57
+ export type FleetCrashEvent = Omit<FleetMiniappCrashPayload, "executionSessionId">
58
+ export type FleetInstallEvent = FleetMiniappInstallPayload
59
+ /** A pairing or connection change: the collector adds the glasses, their model and the Mentra App's version. */
60
+ export type FleetConnectionEvent = Pick<FleetConnectionEventPayload, "event" | "category" | "reconnectMs">
61
+
37
62
  export interface FleetEventListener {
38
63
  launch?: (event: FleetLaunchEvent) => void
39
64
  photo?: (event: FleetPhotoEvent) => void
65
+ telemetry?: (event: FleetTelemetryEvent) => void
66
+ crash?: (event: FleetCrashEvent) => void
67
+ install?: (event: FleetInstallEvent) => void
68
+ connection?: (event: FleetConnectionEvent) => void
40
69
  }
41
70
 
42
71
  /** More launches than this before the collector starts are not one boot's restores; the oldest go first. */
43
72
  const MAX_UNDELIVERED_LAUNCHES = 32
44
73
 
45
74
  const listeners = new Set<FleetEventListener>()
75
+ let lastUserDisconnectAt = 0
46
76
  const undelivered: UndeliveredLaunch[] = []
77
+ /** A start under way that reports its own launch: why a spawn it held back failed, if one did. */
78
+ interface LaunchClaim {
79
+ failureReason?: FleetLaunchFailureReason
80
+ }
81
+
47
82
  /** Starts under way that report their own launch, by package. */
48
- const claims = new Map<string, number>()
83
+ const claims = new Map<string, Set<LaunchClaim>>()
49
84
 
50
85
  function warnListenerFailed(error: unknown): void {
51
86
  // The event is left out of the warning: it names a package or an operation.
@@ -86,20 +121,34 @@ export const fleetEvents = {
86
121
  * same miniapp is under way, which reports it. Never throws.
87
122
  */
88
123
  launchUnlessClaimed(event: FleetLaunchEvent): void {
89
- if ((claims.get(event.packageName) ?? 0) > 0) return
90
- deliverLaunch(event)
124
+ const held = claims.get(event.packageName)
125
+ if (!held) {
126
+ deliverLaunch(event)
127
+ return
128
+ }
129
+ // The start reports this attempt as its own, so it keeps the reason the spawn failed for.
130
+ const reason = event.failureReason
131
+ if (reason === undefined) return
132
+ for (const claim of held) if (claim.failureReason === undefined || claim.failureReason === "unknown") claim.failureReason = reason
91
133
  },
92
134
 
93
- /** A start of `packageName` that reports its own launch is under way, until the returned function is called. */
94
- claimLaunch(packageName: string): () => void {
95
- claims.set(packageName, (claims.get(packageName) ?? 0) + 1)
135
+ /**
136
+ * A start of `packageName` that reports its own launch is under way, until the returned function is called. That
137
+ * function returns why a spawn the start held back failed, for the start's own report, or undefined.
138
+ */
139
+ claimLaunch(packageName: string): () => FleetLaunchFailureReason | undefined {
140
+ const claim: LaunchClaim = {}
141
+ const held = claims.get(packageName) ?? new Set<LaunchClaim>()
142
+ held.add(claim)
143
+ claims.set(packageName, held)
96
144
  let released = false
97
145
  return () => {
98
- if (released) return
99
- released = true
100
- const left = (claims.get(packageName) ?? 1) - 1
101
- if (left > 0) claims.set(packageName, left)
102
- else claims.delete(packageName)
146
+ if (!released) {
147
+ released = true
148
+ held.delete(claim)
149
+ if (held.size === 0 && claims.get(packageName) === held) claims.delete(packageName)
150
+ }
151
+ return claim.failureReason
103
152
  }
104
153
  },
105
154
 
@@ -115,13 +164,48 @@ export const fleetEvents = {
115
164
 
116
165
  /** A photo operation reached a stage. Never throws. */
117
166
  photo(event: FleetPhotoEvent): void {
118
- if (listeners.size === 0) return
119
- for (const listener of [...listeners]) {
120
- try {
121
- listener.photo?.(event)
122
- } catch (error) {
123
- warnListenerFailed(error)
124
- }
125
- }
167
+ deliver("photo", event)
168
+ },
169
+
170
+ /** A miniapp captured one of its own events. Never throws. */
171
+ telemetry(event: FleetTelemetryEvent): void {
172
+ deliver("telemetry", event)
173
+ },
174
+
175
+ /** A miniapp crashed. Never throws. */
176
+ crash(event: FleetCrashEvent): void {
177
+ deliver("crash", event)
126
178
  },
179
+
180
+ /** An install, update or removal of a miniapp was attempted. Never throws. */
181
+ install(event: FleetInstallEvent): void {
182
+ deliver("install", event)
183
+ },
184
+
185
+ /** The person asked the glasses to disconnect (or forgot or unpaired them): the next disconnect is theirs. */
186
+ userDisconnect(): void {
187
+ lastUserDisconnectAt = Date.now()
188
+ },
189
+
190
+ /** When the person last asked the glasses to disconnect (epoch ms), or 0. */
191
+ lastUserDisconnect(): number {
192
+ return lastUserDisconnectAt
193
+ },
194
+
195
+ /** The glasses' pairing or connection changed. Never throws. */
196
+ connection(event: FleetConnectionEvent): void {
197
+ deliver("connection", event)
198
+ },
199
+ }
200
+
201
+ /** Hands `event` to every listener of `kind`; with none, it is dropped. */
202
+ function deliver<K extends "photo" | "telemetry" | "crash" | "install" | "connection">(kind: K, event: Parameters<NonNullable<FleetEventListener[K]>>[0]): void {
203
+ if (listeners.size === 0) return
204
+ for (const listener of [...listeners]) {
205
+ try {
206
+ ;(listener[kind] as ((value: typeof event) => void) | undefined)?.(event)
207
+ } catch (error) {
208
+ warnListenerFailed(error)
209
+ }
210
+ }
127
211
  }