@glassly/engine 0.3.7-dev.964 → 0.3.7-dev.969

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.
@@ -146,6 +146,9 @@ export class JSRouter {
146
146
  */
147
147
  uiRouter: UIRouter | null = null
148
148
 
149
+ /** Teardowns in progress, so a stop and a racing start or stop see the same one. */
150
+ private readonly unregistering = new Map<string, Promise<void>>()
151
+
149
152
  constructor(
150
153
  private readonly runtime: LocalMiniappRuntime,
151
154
  private readonly crust: JSCrustBinding,
@@ -351,12 +354,26 @@ export class JSRouter {
351
354
  }
352
355
 
353
356
  /**
354
- * Tear down a JSContext and unregister from the runtime. Synchronous
355
- * teardown — the spec drops the 50ms WILL_DISCONNECT grace window
356
- * because there's no transport handshake to flush in the JSC world.
357
+ * Tear down a JSContext and unregister from the runtime. The session's
358
+ * `onBeforeDisconnect` handlers get a short grace window first, so a
359
+ * miniapp can clear what it drew and end its own work while its messages
360
+ * still reach the host. Concurrent calls share one teardown.
357
361
  */
358
- async unregister(packageName: string): Promise<void> {
359
- if (!this.registered.has(packageName)) return
362
+ unregister(packageName: string): Promise<void> {
363
+ if (!this.registered.has(packageName)) return Promise.resolve()
364
+ const inFlight = this.unregistering.get(packageName)
365
+ if (inFlight) return inFlight
366
+ const teardown = this.teardown(packageName).finally(() => this.unregistering.delete(packageName))
367
+ this.unregistering.set(packageName, teardown)
368
+ return teardown
369
+ }
370
+
371
+ /** Resolves once an in-flight {@link unregister} of this package has finished. */
372
+ async whenUnregistered(packageName: string): Promise<void> {
373
+ await this.unregistering.get(packageName)
374
+ }
375
+
376
+ private async teardown(packageName: string): Promise<void> {
360
377
  // Cancel any pending crash-respawn before tearing down — we don't
361
378
  // want a stale timer to re-spawn a miniapp the user just disabled.
362
379
  const pending = this.respawnTimers.get(packageName)
@@ -364,6 +381,7 @@ export class JSRouter {
364
381
  BgTimer.clearTimeout(pending)
365
382
  this.respawnTimers.delete(packageName)
366
383
  }
384
+ await this.runtime.notifyWillDisconnect(packageName, "stopped")
367
385
  this.spawnCache.delete(packageName)
368
386
  this.crashController?.onKill(packageName)
369
387
  // User-initiated close/disable: end any active nav trip so the glasses
@@ -340,6 +340,8 @@ const SYSTEM_MINIAPP_PACKAGES = new Set([
340
340
  "com.glassly.miniappdev",
341
341
  ])
342
342
  const PING_INTERVAL_MS = 5_000
343
+ /** How long a stopping miniapp's `beforeDisconnect` handlers get before teardown. */
344
+ const WILL_DISCONNECT_GRACE_MS = 100
343
345
  // Min gap between "transcription limit reached" notices on the glasses. A
344
346
  // debounce, not a cooldown: the cloud re-announces the block per refused
345
347
  // subscribe, and miniapps retry in bursts — this only coalesces those bursts
@@ -1249,21 +1251,26 @@ class LocalMiniappRuntime {
1249
1251
  * undeliverable anyway.
1250
1252
  */
1251
1253
  public async gracefullyUnregisterApp(packageName: string, reason = "unregistering"): Promise<void> {
1252
- const app = this.connectedApps.get(packageName)
1253
- if (!app) return
1254
-
1255
- console.log(`${LOG_TAG}: gracefullyUnregisterApp(${packageName}) — sending WILL_DISCONNECT`)
1256
- this.sendToMiniapp(packageName, {
1257
- type: MiniappResponseType.WILL_DISCONNECT,
1258
- reason,
1259
- })
1260
- // 50 ms: imperceptible to the user, plenty of time for the SDK's
1261
- // synchronous `beforeDisconnect` handlers to flush a final
1262
- // sendOneShot through the still-open transport.
1263
- await new Promise<void>((resolve) => BgTimer.setTimeout(resolve, 50))
1254
+ if (!this.connectedApps.has(packageName)) return
1255
+ await this.notifyWillDisconnect(packageName, reason)
1264
1256
  this.unregisterApp(packageName)
1265
1257
  }
1266
1258
 
1259
+ /**
1260
+ * Send `WILL_DISCONNECT` and wait out the grace window, so the session's
1261
+ * `beforeDisconnect` handlers run (Navigation clears its HUD and ends its
1262
+ * trip) and their last messages land before teardown. No-op unless the
1263
+ * app finished its handshake.
1264
+ */
1265
+ public async notifyWillDisconnect(packageName: string, reason: string): Promise<void> {
1266
+ if (!this.connectedApps.has(packageName) || !this.handshookApps.has(packageName)) return
1267
+ console.log(`${LOG_TAG}: ${packageName} — sending WILL_DISCONNECT (${reason})`)
1268
+ this.sendToMiniapp(packageName, {type: MiniappResponseType.WILL_DISCONNECT, reason})
1269
+ // The message and the replies it triggers each cross a JSContext hop;
1270
+ // short enough not to slow an app switch noticeably.
1271
+ await new Promise<void>((resolve) => BgTimer.setTimeout(resolve, WILL_DISCONNECT_GRACE_MS))
1272
+ }
1273
+
1267
1274
  public unregisterApp(packageName: string, opts?: {endNavigation?: boolean}): void {
1268
1275
  console.log(`${LOG_TAG}: unregisterApp(${packageName})`)
1269
1276
  const releasedMicGateOverride = micStateCoordinator.clearMiniappGateOverrides(packageName)
@@ -6087,14 +6094,14 @@ class LocalMiniappRuntime {
6087
6094
  if (!app) return false
6088
6095
  // session.miniapps.start comes from the glasses (the dashboard's Apps menu,
6089
6096
  // an Assist voice command), unlike a headless action wake, which carries no source.
6097
+ // The full start, as a phone open runs it: the standard peer stops so this
6098
+ // app owns the display, and an app already running hears the new launch.
6090
6099
  const launchSource = "glasses" as const
6091
- // Running only for its widget: start() promotes it to a normal run, as opening it on the phone does.
6092
- if (app.widgetOnly) return useAppStatusStore.getState().start(app, {skipNavigation: true, launchSource})
6093
- if (app.local) {
6094
- await miniappLauncher.ensureConnected(packageName, undefined, {launchSource})
6095
- return true
6096
- }
6097
- return useAppStatusStore.getState().start(app, {skipNavigation: true, launchSource})
6100
+ const started = await useAppStatusStore.getState().start(app, {skipNavigation: true, launchSource})
6101
+ if (!started) return false
6102
+ // The caller may message the app next; start() resolves at spawn.
6103
+ if (app.local) await miniappLauncher.ensureConnected(packageName, undefined, {launchSource})
6104
+ return true
6098
6105
  }
6099
6106
 
6100
6107
  private stopInteropApp(packageName: string): Promise<void> {
@@ -233,6 +233,8 @@ class MiniappLauncher {
233
233
  */
234
234
  async ensureRunning(packageName: string, hints?: LaunchHints): Promise<LaunchResult> {
235
235
  const router = this.requireRouter()
236
+ // A context still tearing down from a stop is not one to reuse.
237
+ await router.whenUnregistered(packageName)
236
238
 
237
239
  // Already spawned: best-effort resolve for the UI entry, never throw —
238
240
  // a headless caller that doesn't need UI gets {null, null} fast even if
@@ -29,7 +29,7 @@ export function isCustomDashboardEligible(info: {model?: string | null; firmware
29
29
  if (info.model === DeviceTypes.MEMOMIND_ONE) {
30
30
  const match = /^GLASSLYCFW\/([0-9]+)$/.exec(info.firmwareVersion ?? "")
31
31
  const revision = match ? Number(match[1]) : 0
32
- return Number.isSafeInteger(revision) && revision >= 5
32
+ return Number.isSafeInteger(revision) && revision >= 7
33
33
  }
34
34
  return (
35
35
  getModelCapabilities(info.model as DeviceTypes).hasCustomFirmware === true &&
@@ -38,6 +38,7 @@ export const SETTINGS_BRIDGE_KEYS: Readonly<Record<string, SettingsBridgeAccess>
38
38
  notifications_enabled: "write",
39
39
  notifications_popups: "write",
40
40
  notifications_popup_seconds: "write",
41
+ notifications_batch_seconds: "write",
41
42
  notifications_hold_during_apps: "write",
42
43
  notifications_ignore_while_active: "write",
43
44
  notifications_blocklist: "write",
@@ -1147,6 +1147,13 @@ export const SETTINGS: Record<string, Setting> = {
1147
1147
  saveOnServer: true,
1148
1148
  persist: true,
1149
1149
  },
1150
+ notifications_batch_seconds: {
1151
+ key: "notifications_batch_seconds",
1152
+ defaultValue: () => 0,
1153
+ writable: true,
1154
+ saveOnServer: true,
1155
+ persist: true,
1156
+ },
1150
1157
  // Hold popups while another glasses feature (navigation, a miniapp) is on screen.
1151
1158
  notifications_hold_during_apps: {
1152
1159
  key: "notifications_hold_during_apps",