@glassly/engine 0.3.5-beta.707 → 0.3.5-beta.734

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 (43) hide show
  1. package/build/engine.d.ts +1 -1
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/engine.js +1 -1
  4. package/build/engine.js.map +1 -1
  5. package/build/facades/ai.d.ts +3 -3
  6. package/build/facades/ai.d.ts.map +1 -1
  7. package/build/facades/ai.js +3 -3
  8. package/build/facades/ai.js.map +1 -1
  9. package/build/generated/releaseMetadata.js +3 -3
  10. package/build/generated/releaseMetadata.js.map +1 -1
  11. package/build/services/GlassesTouchInput.d.ts +4 -3
  12. package/build/services/GlassesTouchInput.d.ts.map +1 -1
  13. package/build/services/GlassesTouchInput.js +12 -11
  14. package/build/services/GlassesTouchInput.js.map +1 -1
  15. package/build/services/LocalDisplayManager.d.ts +12 -4
  16. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  17. package/build/services/LocalDisplayManager.js +21 -7
  18. package/build/services/LocalDisplayManager.js.map +1 -1
  19. package/build/services/LocalMiniappRuntime.d.ts +3 -0
  20. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  21. package/build/services/LocalMiniappRuntime.js +40 -9
  22. package/build/services/LocalMiniappRuntime.js.map +1 -1
  23. package/build/services/MiniappExitGesture.d.ts +6 -3
  24. package/build/services/MiniappExitGesture.d.ts.map +1 -1
  25. package/build/services/MiniappExitGesture.js +4 -4
  26. package/build/services/MiniappExitGesture.js.map +1 -1
  27. package/build/services/MiniappWidgetMode.d.ts +6 -4
  28. package/build/services/MiniappWidgetMode.d.ts.map +1 -1
  29. package/build/services/MiniappWidgetMode.js +7 -6
  30. package/build/services/MiniappWidgetMode.js.map +1 -1
  31. package/build/stores/apps.d.ts.map +1 -1
  32. package/build/stores/apps.js +13 -11
  33. package/build/stores/apps.js.map +1 -1
  34. package/package.json +7 -7
  35. package/src/engine.ts +1 -1
  36. package/src/facades/ai.ts +3 -3
  37. package/src/generated/releaseMetadata.ts +3 -3
  38. package/src/services/GlassesTouchInput.ts +12 -11
  39. package/src/services/LocalDisplayManager.ts +22 -8
  40. package/src/services/LocalMiniappRuntime.ts +37 -9
  41. package/src/services/MiniappExitGesture.ts +8 -5
  42. package/src/services/MiniappWidgetMode.ts +9 -7
  43. package/src/stores/apps.ts +12 -10
@@ -17,7 +17,7 @@ import {pagesEligible} from "./pages/pageEligibility"
17
17
 
18
18
  const miniappExitGesture = new MiniappExitGesture({
19
19
  getState: () => useAppStatusStore.getState(),
20
- getVisiblePackageName: () => localDisplayManager.getVisibleMiniappPackageName(),
20
+ getTargetPackageName: () => localDisplayManager.getMiniappToLeave(),
21
21
  beginExitNotice: (packageName, text) => localDisplayManager.beginMiniappExit(packageName, text),
22
22
  finishExitNotice: (packageName) => localDisplayManager.finishMiniappExit(packageName),
23
23
  cancelExitNotice: (packageName) => localDisplayManager.cancelMiniappExit(packageName),
@@ -47,9 +47,10 @@ function shouldForwardScroll(event: TouchEvent): boolean {
47
47
  * Route both real glasses input and phone mirror controls through the same
48
48
  * policy. A claimed gesture goes to the app holding the claim, alone; an
49
49
  * unclaimed one goes to every subscriber and then the engine's default. The
50
- * one rule ahead of the claims: an unclaimed double tap on a miniapp whose
51
- * frame is visibly on the glasses leaves that app, whoever else claimed the
52
- * gesture — the dashboard's claim is for the empty screen. Head position
50
+ * one rule ahead of the claims: an unclaimed double tap leaves the miniapp
51
+ * the wearer is in — the one visibly on the glasses, else the running
52
+ * standard app on an empty screen — whoever else claimed the gesture; the
53
+ * dashboard's claim is for the screen with no app in it. Head position
53
54
  * (head_up / head_down) is a gesture like any other: the dashboard claims it
54
55
  * while it runs, and an unclaimed head_up on glasses that defer the dashboard
55
56
  * to the phone opens the native one, as it would with no app at all.
@@ -63,16 +64,16 @@ export function dispatchTouchEvent(event: TouchEvent): void {
63
64
  const osHeadUp = gesture === "head_up"
64
65
 
65
66
  if (osDoubleTap) {
66
- // A visible main miniapp opts out of OS exit by registering double tap (the
67
+ // The app being left opts out of OS exit by registering double tap (the
67
68
  // terminal's back-to-menu). The latest claimant still receives it. Otherwise
68
69
  // the tap means "leave the app". The controller shows the bounded OS
69
- // notice first, then uses the normal
70
- // store stop path (and closes its phone overlay).
71
- const visible = localDisplayManager.getVisibleMiniappPackageName()
70
+ // notice first, then uses the normal store stop path (and closes its
71
+ // phone overlay).
72
+ const leaving = localDisplayManager.getMiniappToLeave()
72
73
  if (
73
- visible !== null &&
74
- !localMiniappRuntime.hasGestureClaim(visible, gesture) &&
75
- miniappExitGesture.tryExitVisibleMiniapp()
74
+ leaving !== null &&
75
+ !localMiniappRuntime.hasGestureClaim(leaving, gesture) &&
76
+ miniappExitGesture.tryExitMiniapp()
76
77
  ) {
77
78
  gestureDotsService.noteGesture(gesture, true, event.index, event.controllerId)
78
79
  return
@@ -165,7 +165,7 @@ class LocalDisplayManager {
165
165
  private overlayOwner: string | null = null
166
166
 
167
167
  /** Miniapp whose visible frame is temporarily covered by an OS exit notice. */
168
- private exitingApp: {packageName: string; coveredDisplay: ActiveDisplay} | null = null
168
+ private exitingApp: {packageName: string; coveredDisplay: ActiveDisplay | null} | null = null
169
169
 
170
170
  private expiryTimerId: number | null = null
171
171
 
@@ -309,16 +309,30 @@ class LocalDisplayManager {
309
309
  }
310
310
 
311
311
  /**
312
- * Cover the currently visible miniapp with a persistent system exit notice.
313
- * While it is up, miniapp display traffic is held off so a streaming app
314
- * cannot immediately overwrite the notice. The caller completes the normal
315
- * stop flow and then calls finishMiniappExit(); onUnmount does the same as a
312
+ * The miniapp an unclaimed double tap leaves: the one visibly drawing on
313
+ * the main view, else the core (running standard) app on an empty or
314
+ * system-drawn screen — an app that has drawn nothing yet, or whose frame
315
+ * expired, is still the one the wearer is in. Nothing while a page covers
316
+ * the main view: the page's claims own the tap.
317
+ */
318
+ public getMiniappToLeave(): string | null {
319
+ if (this.overlayOwner !== null) return null
320
+ return this.getVisibleMiniappPackageName() ?? this.coreApp
321
+ }
322
+
323
+ /**
324
+ * Cover the leaving miniapp with a persistent system exit notice. While it
325
+ * is up, miniapp display traffic is held off so a streaming app cannot
326
+ * immediately overwrite the notice. The caller completes the normal stop
327
+ * flow and then calls finishMiniappExit(); onUnmount does the same as a
316
328
  * safety net.
317
329
  */
318
330
  public beginMiniappExit(packageName: string, text: string): boolean {
319
331
  if (this.exitingApp !== null) return this.exitingApp.packageName === packageName
320
- const coveredDisplay = this.currentDisplay
321
- if (!coveredDisplay || this.getVisibleMiniappPackageName() !== packageName) return false
332
+ if (this.getMiniappToLeave() !== packageName) return false
333
+ // Only the app's own visible frame comes back on cancel; a system frame
334
+ // or an empty screen does not.
335
+ const coveredDisplay = this.getVisibleMiniappPackageName() === packageName ? this.currentDisplay : null
322
336
 
323
337
  console.log(`${LOG_TAG}: beginMiniappExit(${packageName})`)
324
338
  this.exitingApp = {packageName, coveredDisplay}
@@ -349,7 +363,7 @@ class LocalDisplayManager {
349
363
  this.exitingApp = null
350
364
 
351
365
  if (this.currentDisplay?.packageName !== SYSTEM_NOTICE_PKG) return
352
- if (covered.expiresAt === null || covered.expiresAt > this.now()) {
366
+ if (covered && (covered.expiresAt === null || covered.expiresAt > this.now())) {
353
367
  this.restoreDisplay(covered)
354
368
  } else {
355
369
  this.currentDisplay = null
@@ -641,6 +641,10 @@ class LocalMiniappRuntime {
641
641
  private connectedApps: Map<string, ConnectedMiniapp> = new Map()
642
642
  /** Claimed gestures (CLAIM_GESTURES): a stack per gesture, the most recent claimer holding it. */
643
643
  private gestureClaims: GestureClaimState = emptyGestureClaims()
644
+ // The last main-view frame each app asked to render. Widget mode refuses
645
+ // frames and demotion clears them, and an app that has drawn once may not
646
+ // draw again on its own, so promotion replays this.
647
+ private retainedRenders = new Map<string, Record<string, unknown>>()
644
648
 
645
649
  /**
646
650
  * Packages that have completed their CONNECT handshake (sent
@@ -705,7 +709,13 @@ class LocalMiniappRuntime {
705
709
  private localSecret = `miniapp_${Date.now()}_${Math.random().toString(36).slice(2, 14)}`
706
710
  private usedTokens = new Set<string>()
707
711
 
708
- private constructor() {}
712
+ private constructor() {
713
+ miniappWidgetMode.subscribe((packageName, widgetOnly) => {
714
+ // Promotion happens inside the apps store's start(); the core-app slot
715
+ // settles with that store write, so replay once it has.
716
+ if (!widgetOnly) queueMicrotask(() => this.replayRetainedRender(packageName))
717
+ })
718
+ }
709
719
 
710
720
  /** Snapshot of miniapps actively subscribed to hardware button events. */
711
721
  public getButtonPressSubscribers(): string[] {
@@ -1122,7 +1132,11 @@ class LocalMiniappRuntime {
1122
1132
  // window ends early once this app pushes its first display (see
1123
1133
  // LocalDisplayManager.request), or after ~1.5s. Fires once per spawn —
1124
1134
  // including a crash-respawn, since each spawn is a fresh "app is starting".
1125
- localDisplayManager.onMount(packageName, installedManifest?.name ?? packageName)
1135
+ // A spawn for a dashboard widget is not the wearer starting the app: it draws
1136
+ // nothing of its own, so it shows no boot text either.
1137
+ if (!miniappWidgetMode.isWidgetOnly(packageName)) {
1138
+ localDisplayManager.onMount(packageName, installedManifest?.name ?? packageName)
1139
+ }
1126
1140
  }
1127
1141
 
1128
1142
  /**
@@ -1244,6 +1258,7 @@ class LocalMiniappRuntime {
1244
1258
  }
1245
1259
  // Its gesture claims go back to whoever claimed them before.
1246
1260
  this.applyGestureClaimChanges(dropGestureClaims(this.gestureClaims, packageName))
1261
+ this.retainedRenders.delete(packageName)
1247
1262
 
1248
1263
  // Warm-up is a request-owned camera lease. Cancel it even if the app record was already
1249
1264
  // removed so a close racing the camera-open promise cannot leave the sensor running.
@@ -2489,11 +2504,6 @@ class LocalMiniappRuntime {
2489
2504
  this.sendResult(packageName, requestId, true, {status: "blocked", reason: "app stopped"})
2490
2505
  return
2491
2506
  }
2492
- // A widget-only app draws through its widget alone.
2493
- if (miniappWidgetMode.isWidgetOnly(packageName)) {
2494
- this.sendResult(packageName, requestId, true, {status: "blocked", reason: "widget mode"})
2495
- return
2496
- }
2497
2507
  if (!Array.isArray(payload.elements)) {
2498
2508
  this.sendResult(packageName, requestId, false, undefined, {
2499
2509
  code: MiniappErrorCode.INTERNAL,
@@ -2501,6 +2511,15 @@ class LocalMiniappRuntime {
2501
2511
  })
2502
2512
  return
2503
2513
  }
2514
+ if (payload.view !== "dashboard") {
2515
+ if (payload.elements.length === 0) this.retainedRenders.delete(packageName)
2516
+ else this.retainedRenders.set(packageName, payload)
2517
+ }
2518
+ // A widget-only app draws through its widget alone; the frame waits for promotion.
2519
+ if (miniappWidgetMode.isWidgetOnly(packageName)) {
2520
+ this.sendResult(packageName, requestId, true, {status: "blocked", reason: "widget mode"})
2521
+ return
2522
+ }
2504
2523
  // The dashboard view is the overlay app's alone; anyone else's frame
2505
2524
  // is dropped, reported rather than silently lost.
2506
2525
  if (payload.view === "dashboard" && !isOverlayApp(packageName)) {
@@ -2530,6 +2549,13 @@ class LocalMiniappRuntime {
2530
2549
  }
2531
2550
  }
2532
2551
 
2552
+ /** Put a promoted app's last main-view frame on the glasses; widget mode had refused it. */
2553
+ private replayRetainedRender(packageName: string): void {
2554
+ const payload = this.retainedRenders.get(packageName)
2555
+ if (!payload || !this.connectedApps.has(packageName) || miniappWidgetMode.isWidgetOnly(packageName)) return
2556
+ this.handleRender(packageName, payload)
2557
+ }
2558
+
2533
2559
  /**
2534
2560
  * display.showView: the active dashboard takes or releases the overlay
2535
2561
  * slot. Refused (NOT_PERMITTED) for an app that is not a dashboard;
@@ -6381,10 +6407,12 @@ class LocalMiniappRuntime {
6381
6407
  }
6382
6408
  const provider = app.packageName
6383
6409
  // Record the placement before the wake so the spawn lands in widget mode
6384
- // and an unplace racing it cannot stop the provider early.
6410
+ // and an unplace racing it cannot stop the provider early. A provider the
6411
+ // store already runs (a wearer's launch still spawning, say) stays a
6412
+ // normal run; the registry alone lags that launch.
6385
6413
  this.widgetPlacements = placeWidget(this.widgetPlacements, {hostPackageName, providerPackageName: provider, widgetId, box})
6386
6414
  miniappWidgetMode.setPlaced(provider, true)
6387
- if (!miniappRunningRegistry.has(provider)) miniappWidgetMode.setWidgetOnly(provider, true)
6415
+ if (!app.running && !miniappRunningRegistry.has(provider)) miniappWidgetMode.setWidgetOnly(provider, true)
6388
6416
  try {
6389
6417
  await this.wakeInteropApp(provider)
6390
6418
  } catch (e) {
@@ -4,6 +4,8 @@ interface ExitGestureApp {
4
4
  packageName: string
5
5
  name: string
6
6
  running: boolean
7
+ /** Running only for a placed widget: the wearer is not in it, so there is nothing to leave. */
8
+ widgetOnly?: boolean
7
9
  }
8
10
 
9
11
  interface ExitGestureState {
@@ -15,7 +17,8 @@ interface ExitGestureState {
15
17
 
16
18
  export interface ExitGestureDependencies {
17
19
  getState: () => ExitGestureState
18
- getVisiblePackageName: () => string | null
20
+ /** The miniapp a double tap leaves right now, or null when the tap is the system's. */
21
+ getTargetPackageName: () => string | null
19
22
  beginExitNotice: (packageName: string, text: string) => boolean
20
23
  finishExitNotice: (packageName: string) => void
21
24
  cancelExitNotice: (packageName: string) => void
@@ -29,16 +32,16 @@ export class MiniappExitGesture {
29
32
 
30
33
  public constructor(private readonly deps: ExitGestureDependencies) {}
31
34
 
32
- /** Returns true when the system gesture was consumed by a visible miniapp exit. */
33
- public tryExitVisibleMiniapp(): boolean {
35
+ /** Returns true when the system gesture was consumed by a miniapp exit. */
36
+ public tryExitMiniapp(): boolean {
34
37
  // Swallow repeat taps while the notice is already counting down.
35
38
  if (this.pending) return true
36
39
 
37
- const packageName = this.deps.getVisiblePackageName()
40
+ const packageName = this.deps.getTargetPackageName()
38
41
  if (!packageName) return false
39
42
 
40
43
  const app = this.deps.getState().apps.find((candidate) => candidate.packageName === packageName)
41
- if (!app?.running) return false
44
+ if (!app?.running || app.widgetOnly) return false
42
45
 
43
46
  const name = app.name.trim() || packageName
44
47
  if (!this.deps.beginExitNotice(packageName, `Exiting ${name}…`)) return false
@@ -2,25 +2,27 @@
2
2
  * Widget mode: a miniapp whose background context runs only because a host
3
3
  * (the dashboard) placed one of its widgets. It is running, but it has no
4
4
  * card in the app switcher, draws nothing of its own on the glasses and
5
- * hears no touch input; the Widgets pill lists it. Opening the app from the
6
- * home screen promotes it to a normal run; closing a placed app from the
7
- * tray demotes it back here instead of stopping it.
5
+ * hears no touch input; the Widgets pill lists it. Foregrounding the app on
6
+ * the phone promotes it to a normal run (the store's start/setForeground);
7
+ * closing a placed app from the tray demotes it back here instead of
8
+ * stopping it.
8
9
  *
9
10
  * The runtime is the writer (placements come and go through it); the apps
10
11
  * store projects `widgetOnly` from here beside `running`.
11
12
  */
12
13
 
13
- type Listener = () => void
14
+ /** Called with the app whose mode changed and whether it is now widget-only. */
15
+ type Listener = (packageName: string, widgetOnly: boolean) => void
14
16
 
15
17
  const widgetOnly = new Set<string>()
16
18
  /** Providers with at least one widget placed by some host. */
17
19
  const placed = new Set<string>()
18
20
  const listeners = new Set<Listener>()
19
21
 
20
- function notify(): void {
22
+ function notify(packageName: string, on: boolean): void {
21
23
  for (const fn of listeners) {
22
24
  try {
23
- fn()
25
+ fn(packageName, on)
24
26
  } catch (e) {
25
27
  console.warn("MiniappWidgetMode: listener threw", e)
26
28
  }
@@ -32,7 +34,7 @@ export const miniappWidgetMode = {
32
34
  setWidgetOnly(packageName: string, on: boolean): void {
33
35
  const changed = on ? !widgetOnly.has(packageName) : widgetOnly.delete(packageName)
34
36
  if (on) widgetOnly.add(packageName)
35
- if (changed) notify()
37
+ if (changed) notify(packageName, on)
36
38
  },
37
39
 
38
40
  isWidgetOnly(packageName: string): boolean {
@@ -256,6 +256,7 @@ function projectApps(previousState: AppStatusState, localApps: ClientApp[]): Cli
256
256
  // across both passes.
257
257
  foregrounded: app.packageName === previousState.foregroundedPackage,
258
258
  paused: app.local && app.running && isMiniappsPaused(),
259
+ widgetOnly: !!app.local && app.running && miniappWidgetMode.isWidgetOnly(app.packageName),
259
260
  }
260
261
  const prev = previousByPackage.get(app.packageName)
261
262
  return prev && shallowEqualApp(prev, next) ? prev : next
@@ -362,6 +363,11 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
362
363
  return false
363
364
  }
364
365
 
366
+ // An app running only for its widget is promoted before the host hook:
367
+ // the hook may foreground it, and setForeground sends a widget-only app
368
+ // back here.
369
+ if (app.widgetOnly) miniappWidgetMode.setWidgetOnly(packageName, false)
370
+
365
371
  // Host navigation / open-animation seam. Runs on EVERY accepted open —
366
372
  // including re-opening an already-running app — so the animation always
367
373
  // plays (BuiltInMiniappCatalog.navigateForApp: overlay apps setForeground
@@ -374,13 +380,12 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
374
380
 
375
381
  // If the tapped app is already running, this is a re-open: the nav/foreground
376
382
  // above already drove the animation, and there's nothing to spawn. Short-
377
- // circuit before the spawn pipeline. An app running only for its widget
378
- // is promoted instead: it keeps its context and takes the foreground.
383
+ // circuit before the spawn pipeline. A promoted app keeps its context but
384
+ // still takes the foreground below.
379
385
  if (app.running && !app.widgetOnly) {
380
386
  saveLastOpenTime(packageName)
381
387
  return true
382
388
  }
383
- if (app.widgetOnly) miniappWidgetMode.setWidgetOnly(packageName, false)
384
389
 
385
390
  // Only-one rules: one standard app in the foreground, one dashboard
386
391
  // drawing the overlay. Starting another stops the one running. Widget-only
@@ -495,13 +500,10 @@ export const useAppStatusStore = create<AppStatusState>((set, get) => ({
495
500
  apps: s.apps.map((a) => ({...a, foregrounded: a.packageName === packageName})),
496
501
  }))
497
502
 
498
- // Ensure the JSContext exists. start() is idempotent for an
499
- // already-running app and enforces the foreground-only-one rule for
500
- // standard apps. Fire-and-forget after the flag flip so it doesn't block
501
- // paint.
502
- // if (!app.running) {
503
- // get().start(app)
504
- // }
503
+ // The wearer is in this app now: one running only for its widget becomes
504
+ // a normal run. start() promotes it, stops the standard peer and keeps the
505
+ // context. Fire-and-forget after the flag flip so it doesn't block paint.
506
+ if (app.running && app.widgetOnly) void get().start(app, {skipNavigation: true})
505
507
  },
506
508
 
507
509
  clearForeground: () => {