@glassly/engine 0.3.5-beta.710 → 0.3.5-beta.760

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 (119) hide show
  1. package/build/engine.d.ts +4 -1
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/engine.js +5 -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/facades/glasses.d.ts +5 -1
  10. package/build/facades/glasses.d.ts.map +1 -1
  11. package/build/facades/glasses.js +5 -0
  12. package/build/facades/glasses.js.map +1 -1
  13. package/build/generated/releaseMetadata.js +3 -3
  14. package/build/generated/releaseMetadata.js.map +1 -1
  15. package/build/services/GestureDotsService.d.ts.map +1 -1
  16. package/build/services/GestureDotsService.js +2 -1
  17. package/build/services/GestureDotsService.js.map +1 -1
  18. package/build/services/GlassesTouchInput.d.ts +6 -4
  19. package/build/services/GlassesTouchInput.d.ts.map +1 -1
  20. package/build/services/GlassesTouchInput.js +76 -37
  21. package/build/services/GlassesTouchInput.js.map +1 -1
  22. package/build/services/LocalDisplayManager.d.ts +12 -4
  23. package/build/services/LocalDisplayManager.d.ts.map +1 -1
  24. package/build/services/LocalDisplayManager.js +21 -7
  25. package/build/services/LocalDisplayManager.js.map +1 -1
  26. package/build/services/LocalLlmModelManager.d.ts +3 -1
  27. package/build/services/LocalLlmModelManager.d.ts.map +1 -1
  28. package/build/services/LocalLlmModelManager.js +13 -5
  29. package/build/services/LocalLlmModelManager.js.map +1 -1
  30. package/build/services/LocalLlmPrompt.d.ts +10 -9
  31. package/build/services/LocalLlmPrompt.d.ts.map +1 -1
  32. package/build/services/LocalLlmPrompt.js +43 -32
  33. package/build/services/LocalLlmPrompt.js.map +1 -1
  34. package/build/services/LocalLlmProvider.d.ts.map +1 -1
  35. package/build/services/LocalLlmProvider.js +39 -20
  36. package/build/services/LocalLlmProvider.js.map +1 -1
  37. package/build/services/LocalMiniappRuntime.d.ts +22 -4
  38. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  39. package/build/services/LocalMiniappRuntime.js +108 -134
  40. package/build/services/LocalMiniappRuntime.js.map +1 -1
  41. package/build/services/MiniappExitGesture.d.ts +6 -3
  42. package/build/services/MiniappExitGesture.d.ts.map +1 -1
  43. package/build/services/MiniappExitGesture.js +4 -4
  44. package/build/services/MiniappExitGesture.js.map +1 -1
  45. package/build/services/MiniappWidgetMode.d.ts +6 -4
  46. package/build/services/MiniappWidgetMode.d.ts.map +1 -1
  47. package/build/services/MiniappWidgetMode.js +7 -6
  48. package/build/services/MiniappWidgetMode.js.map +1 -1
  49. package/build/services/SceneRenderer.d.ts.map +1 -1
  50. package/build/services/SceneRenderer.js +3 -1
  51. package/build/services/SceneRenderer.js.map +1 -1
  52. package/build/services/actionInvoke.d.ts +37 -0
  53. package/build/services/actionInvoke.d.ts.map +1 -0
  54. package/build/services/actionInvoke.js +89 -0
  55. package/build/services/actionInvoke.js.map +1 -0
  56. package/build/services/appleWatch/AppleWatchLink.d.ts +42 -0
  57. package/build/services/appleWatch/AppleWatchLink.d.ts.map +1 -0
  58. package/build/services/appleWatch/AppleWatchLink.js +259 -0
  59. package/build/services/appleWatch/AppleWatchLink.js.map +1 -0
  60. package/build/services/controllers.d.ts.map +1 -1
  61. package/build/services/controllers.js +16 -0
  62. package/build/services/controllers.js.map +1 -1
  63. package/build/services/settingsBridge.d.ts +4 -4
  64. package/build/services/settingsBridge.d.ts.map +1 -1
  65. package/build/services/settingsBridge.js +9 -4
  66. package/build/services/settingsBridge.js.map +1 -1
  67. package/build/stores/apps.d.ts.map +1 -1
  68. package/build/stores/apps.js +13 -11
  69. package/build/stores/apps.js.map +1 -1
  70. package/build/stores/bluetoothSettingKeys.d.ts +2 -0
  71. package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
  72. package/build/stores/bluetoothSettingKeys.js +17 -0
  73. package/build/stores/bluetoothSettingKeys.js.map +1 -1
  74. package/build/stores/settings.d.ts.map +1 -1
  75. package/build/stores/settings.js +32 -2
  76. package/build/stores/settings.js.map +1 -1
  77. package/build/types/capabilities/rayneo-io.d.ts.map +1 -1
  78. package/build/types/capabilities/rayneo-io.js +2 -0
  79. package/build/types/capabilities/rayneo-io.js.map +1 -1
  80. package/build/types/enums.d.ts +2 -1
  81. package/build/types/enums.d.ts.map +1 -1
  82. package/build/types/enums.js +1 -0
  83. package/build/types/enums.js.map +1 -1
  84. package/build/utils/display/scene/index.d.ts +1 -1
  85. package/build/utils/display/scene/index.d.ts.map +1 -1
  86. package/build/utils/display/scene/index.js.map +1 -1
  87. package/build/utils/display/scene/process.d.ts.map +1 -1
  88. package/build/utils/display/scene/process.js +20 -1
  89. package/build/utils/display/scene/process.js.map +1 -1
  90. package/build/utils/display/scene/types.d.ts +1 -1
  91. package/build/utils/display/scene/types.d.ts.map +1 -1
  92. package/build/utils/display/scene/types.js.map +1 -1
  93. package/package.json +7 -7
  94. package/src/engine.ts +5 -1
  95. package/src/facades/ai.ts +3 -3
  96. package/src/facades/glasses.ts +7 -0
  97. package/src/generated/releaseMetadata.ts +3 -3
  98. package/src/services/GestureDotsService.ts +2 -1
  99. package/src/services/GlassesTouchInput.ts +81 -34
  100. package/src/services/LocalDisplayManager.ts +22 -8
  101. package/src/services/LocalLlmModelManager.ts +16 -6
  102. package/src/services/LocalLlmPrompt.ts +56 -37
  103. package/src/services/LocalLlmProvider.ts +49 -22
  104. package/src/services/LocalMiniappRuntime.ts +140 -134
  105. package/src/services/MiniappExitGesture.ts +8 -5
  106. package/src/services/MiniappWidgetMode.ts +9 -7
  107. package/src/services/SceneRenderer.ts +3 -1
  108. package/src/services/actionInvoke.ts +105 -0
  109. package/src/services/appleWatch/AppleWatchLink.ts +297 -0
  110. package/src/services/controllers.ts +17 -0
  111. package/src/services/settingsBridge.ts +9 -4
  112. package/src/stores/apps.ts +12 -10
  113. package/src/stores/bluetoothSettingKeys.ts +20 -0
  114. package/src/stores/settings.ts +34 -2
  115. package/src/types/capabilities/rayneo-io.ts +2 -0
  116. package/src/types/enums.ts +1 -0
  117. package/src/utils/display/scene/index.ts +1 -0
  118. package/src/utils/display/scene/process.ts +17 -1
  119. package/src/utils/display/scene/types.ts +1 -0
@@ -162,6 +162,13 @@ import {invokeScanQrSeam} from "../runtime/scanQrSeam"
162
162
  import {invokeWebLoginSeam} from "../runtime/webLoginSeam"
163
163
  import {normalizeStreamAudioConfig, normalizeStreamVideoConfig} from "../runtime/streamConfig"
164
164
  import {visibleActionRows} from "./manifestActions"
165
+ import {
166
+ actionFailure,
167
+ appDeclaresAction,
168
+ checkActionAccess,
169
+ PendingActionCalls,
170
+ type ActionOutcome,
171
+ } from "./actionInvoke"
165
172
  import {toLanguageHint} from "@glassly/cloud-protocol/languages"
166
173
  import {buildMiniappManifestSnapshot, type MiniappRuntimeDiagnosticSnapshot} from "../utils/miniappDiagnostics"
167
174
  import ttsModelManager from "./TTSModelManager"
@@ -641,6 +648,10 @@ class LocalMiniappRuntime {
641
648
  private connectedApps: Map<string, ConnectedMiniapp> = new Map()
642
649
  /** Claimed gestures (CLAIM_GESTURES): a stack per gesture, the most recent claimer holding it. */
643
650
  private gestureClaims: GestureClaimState = emptyGestureClaims()
651
+ // The last main-view frame each app asked to render. Widget mode refuses
652
+ // frames and demotion clears them, and an app that has drawn once may not
653
+ // draw again on its own, so promotion replays this.
654
+ private retainedRenders = new Map<string, Record<string, unknown>>()
644
655
 
645
656
  /**
646
657
  * Packages that have completed their CONNECT handshake (sent
@@ -705,7 +716,13 @@ class LocalMiniappRuntime {
705
716
  private localSecret = `miniapp_${Date.now()}_${Math.random().toString(36).slice(2, 14)}`
706
717
  private usedTokens = new Set<string>()
707
718
 
708
- private constructor() {}
719
+ private constructor() {
720
+ miniappWidgetMode.subscribe((packageName, widgetOnly) => {
721
+ // Promotion happens inside the apps store's start(); the core-app slot
722
+ // settles with that store write, so replay once it has.
723
+ if (!widgetOnly) queueMicrotask(() => this.replayRetainedRender(packageName))
724
+ })
725
+ }
709
726
 
710
727
  /** Snapshot of miniapps actively subscribed to hardware button events. */
711
728
  public getButtonPressSubscribers(): string[] {
@@ -1122,7 +1139,11 @@ class LocalMiniappRuntime {
1122
1139
  // window ends early once this app pushes its first display (see
1123
1140
  // LocalDisplayManager.request), or after ~1.5s. Fires once per spawn —
1124
1141
  // including a crash-respawn, since each spawn is a fresh "app is starting".
1125
- localDisplayManager.onMount(packageName, installedManifest?.name ?? packageName)
1142
+ // A spawn for a dashboard widget is not the wearer starting the app: it draws
1143
+ // nothing of its own, so it shows no boot text either.
1144
+ if (!miniappWidgetMode.isWidgetOnly(packageName)) {
1145
+ localDisplayManager.onMount(packageName, installedManifest?.name ?? packageName)
1146
+ }
1126
1147
  }
1127
1148
 
1128
1149
  /**
@@ -1244,6 +1265,7 @@ class LocalMiniappRuntime {
1244
1265
  }
1245
1266
  // Its gesture claims go back to whoever claimed them before.
1246
1267
  this.applyGestureClaimChanges(dropGestureClaims(this.gestureClaims, packageName))
1268
+ this.retainedRenders.delete(packageName)
1247
1269
 
1248
1270
  // Warm-up is a request-owned camera lease. Cancel it even if the app record was already
1249
1271
  // removed so a close racing the camera-open promise cannot leave the sensor running.
@@ -2489,11 +2511,6 @@ class LocalMiniappRuntime {
2489
2511
  this.sendResult(packageName, requestId, true, {status: "blocked", reason: "app stopped"})
2490
2512
  return
2491
2513
  }
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
2514
  if (!Array.isArray(payload.elements)) {
2498
2515
  this.sendResult(packageName, requestId, false, undefined, {
2499
2516
  code: MiniappErrorCode.INTERNAL,
@@ -2501,6 +2518,15 @@ class LocalMiniappRuntime {
2501
2518
  })
2502
2519
  return
2503
2520
  }
2521
+ if (payload.view !== "dashboard") {
2522
+ if (payload.elements.length === 0) this.retainedRenders.delete(packageName)
2523
+ else this.retainedRenders.set(packageName, payload)
2524
+ }
2525
+ // A widget-only app draws through its widget alone; the frame waits for promotion.
2526
+ if (miniappWidgetMode.isWidgetOnly(packageName)) {
2527
+ this.sendResult(packageName, requestId, true, {status: "blocked", reason: "widget mode"})
2528
+ return
2529
+ }
2504
2530
  // The dashboard view is the overlay app's alone; anyone else's frame
2505
2531
  // is dropped, reported rather than silently lost.
2506
2532
  if (payload.view === "dashboard" && !isOverlayApp(packageName)) {
@@ -2530,6 +2556,13 @@ class LocalMiniappRuntime {
2530
2556
  }
2531
2557
  }
2532
2558
 
2559
+ /** Put a promoted app's last main-view frame on the glasses; widget mode had refused it. */
2560
+ private replayRetainedRender(packageName: string): void {
2561
+ const payload = this.retainedRenders.get(packageName)
2562
+ if (!payload || !this.connectedApps.has(packageName) || miniappWidgetMode.isWidgetOnly(packageName)) return
2563
+ this.handleRender(packageName, payload)
2564
+ }
2565
+
2533
2566
  /**
2534
2567
  * display.showView: the active dashboard takes or releases the overlay
2535
2568
  * slot. Refused (NOT_PERMITTED) for an app that is not a dashboard;
@@ -5995,10 +6028,7 @@ class LocalMiniappRuntime {
5995
6028
  private static readonly ACTION_PAYLOAD_CAP = 256 * 1024
5996
6029
 
5997
6030
  /** Outstanding action invocations, keyed by host-generated callId. */
5998
- private actionCalls = new Map<
5999
- string,
6000
- {callerPackageName: string; targetPackageName: string; callerRequestId?: string; timer: number}
6001
- >()
6031
+ private actionCalls = new PendingActionCalls(BgTimer, (value) => this.actionPayloadTooLarge(value))
6002
6032
  private actionCallSeq = 0
6003
6033
 
6004
6034
  private interopApps(): ClientApp[] {
@@ -6381,10 +6411,12 @@ class LocalMiniappRuntime {
6381
6411
  }
6382
6412
  const provider = app.packageName
6383
6413
  // Record the placement before the wake so the spawn lands in widget mode
6384
- // and an unplace racing it cannot stop the provider early.
6414
+ // and an unplace racing it cannot stop the provider early. A provider the
6415
+ // store already runs (a wearer's launch still spawning, say) stays a
6416
+ // normal run; the registry alone lags that launch.
6385
6417
  this.widgetPlacements = placeWidget(this.widgetPlacements, {hostPackageName, providerPackageName: provider, widgetId, box})
6386
6418
  miniappWidgetMode.setPlaced(provider, true)
6387
- if (!miniappRunningRegistry.has(provider)) miniappWidgetMode.setWidgetOnly(provider, true)
6419
+ if (!app.running && !miniappRunningRegistry.has(provider)) miniappWidgetMode.setWidgetOnly(provider, true)
6388
6420
  try {
6389
6421
  await this.wakeInteropApp(provider)
6390
6422
  } catch (e) {
@@ -6568,111 +6600,118 @@ class LocalMiniappRuntime {
6568
6600
  }
6569
6601
  }
6570
6602
 
6603
+ /** Floored at 6s: a freshly-woken target buffers an undelivered ACTION_CALL for 5s (HANDLER_WAIT_MS in @glassly/miniapp's actions module) + 1s for the reply. Keep in sync. */
6604
+ private static clampActionTimeout(raw: unknown): number {
6605
+ return Math.min(Math.max(Number(raw) || 30_000, 6_000), 120_000)
6606
+ }
6607
+
6608
+ /** Caller identity the host presents for host-initiated actions (a system caller). */
6609
+ private static readonly HOST_ACTION_CALLER = "host"
6610
+
6611
+ /** Installed, compatible, and declares the action — the same gates `invokeActionFromHost` applies up front. */
6612
+ public hostActionAvailable(targetPackageName: string, actionId: string): boolean {
6613
+ return appDeclaresAction(
6614
+ this.interopApps().find((a) => a.packageName === targetPackageName),
6615
+ actionId,
6616
+ )
6617
+ }
6618
+
6571
6619
  /**
6572
- * session.actions.invoke → wake the target headlessly, deliver an ACTION_CALL,
6573
- * and correlate its ACTION_RESULT back to the caller's pending request.
6620
+ * Host-initiated action call (e.g. the watch remote): same gates, headless
6621
+ * wake and ACTION_CALL/ACTION_RESULT correlation as a miniapp's
6622
+ * session.actions.invoke, with the host as a system caller.
6623
+ * Rejects with an Error whose `code` is a MiniappErrorCode.
6574
6624
  */
6625
+ public async invokeActionFromHost(
6626
+ targetPackageName: string,
6627
+ actionId: string,
6628
+ params: Record<string, unknown> = {},
6629
+ timeoutMs?: number,
6630
+ ): Promise<unknown> {
6631
+ const outcome = await this.runActionInvoke(
6632
+ LocalMiniappRuntime.HOST_ACTION_CALLER,
6633
+ true,
6634
+ targetPackageName,
6635
+ actionId,
6636
+ params,
6637
+ LocalMiniappRuntime.clampActionTimeout(timeoutMs),
6638
+ )
6639
+ if (outcome.ok) return outcome.data
6640
+ throw Object.assign(new Error(outcome.error.message), {code: outcome.error.code})
6641
+ }
6642
+
6643
+ /** session.actions.invoke → run the call, then relay the outcome to the caller's pending request. */
6575
6644
  private async handleActionInvoke(
6576
6645
  callerPackageName: string,
6577
6646
  payload: Record<string, unknown>,
6578
6647
  requestId?: string,
6579
6648
  ): Promise<void> {
6580
- const target = payload.targetPackageName as string | undefined
6581
- const actionId = payload.actionId as string | undefined
6582
- const params = (payload.params as Record<string, unknown> | undefined) ?? {}
6583
- // Floor the timeout at 6s so the host never rejects with ACTION_TIMEOUT before
6584
- // the target SDK's handler-registration window closes. A freshly-woken miniapp
6585
- // buffers an undelivered ACTION_CALL for HANDLER_WAIT_MS (5s in @glassly/miniapp's
6586
- // actions module) waiting for session.actions.handle; a shorter host timeout
6587
- // could fire while the target is still registering, then the action would run
6588
- // with no caller left to receive its result. 6s = that 5s buffer + 1s for the
6589
- // ACTION_RESULT/NO_ACTION_HANDLER reply to travel back. Keep these in sync.
6590
- const timeoutMs = Math.min(Math.max(Number(payload.timeoutMs) || 30_000, 6_000), 120_000)
6649
+ const outcome = await this.runActionInvoke(
6650
+ callerPackageName,
6651
+ this.isSystemPackage(callerPackageName),
6652
+ payload.targetPackageName as string | undefined,
6653
+ payload.actionId as string | undefined,
6654
+ (payload.params as Record<string, unknown> | undefined) ?? {},
6655
+ LocalMiniappRuntime.clampActionTimeout(payload.timeoutMs),
6656
+ )
6657
+ if (outcome.ok) this.sendResult(callerPackageName, requestId, true, outcome.data)
6658
+ else this.sendResult(callerPackageName, requestId, false, undefined, outcome.error)
6659
+ }
6660
+
6661
+ /**
6662
+ * Gate, wake the target headlessly, deliver an ACTION_CALL, and resolve when
6663
+ * its ACTION_RESULT (or the timeout) arrives. Never rejects.
6664
+ */
6665
+ private async runActionInvoke(
6666
+ callerPackageName: string,
6667
+ callerIsSystem: boolean,
6668
+ target: string | undefined,
6669
+ actionId: string | undefined,
6670
+ params: Record<string, unknown>,
6671
+ timeoutMs: number,
6672
+ ): Promise<ActionOutcome> {
6673
+ const fail = actionFailure
6591
6674
 
6592
6675
  if (this.actionPayloadTooLarge(params)) {
6593
- this.sendResult(callerPackageName, requestId, false, undefined, {
6594
- code: MiniappErrorCode.PAYLOAD_TOO_LARGE,
6595
- message: "action params exceeded the 256 KB cap",
6596
- })
6597
- return
6676
+ return fail(MiniappErrorCode.PAYLOAD_TOO_LARGE, "action params exceeded the 256 KB cap")
6598
6677
  }
6599
6678
 
6600
6679
  const app = target ? this.interopApps().find((a) => a.packageName === target) : undefined
6601
- if (!target || !app) {
6602
- this.sendResult(callerPackageName, requestId, false, undefined, {
6603
- code: MiniappErrorCode.APP_NOT_FOUND,
6604
- message: `Miniapp not found: ${target ?? "(missing targetPackageName)"}`,
6605
- })
6606
- return
6607
- }
6608
- if (!actionId) {
6609
- this.sendResult(callerPackageName, requestId, false, undefined, {
6610
- code: MiniappErrorCode.ACTION_NOT_FOUND,
6611
- message: "invoke requires an actionId",
6612
- })
6613
- return
6614
- }
6615
- if (app.compatibility && app.compatibility.isCompatible === false) {
6616
- this.sendResult(callerPackageName, requestId, false, undefined, {
6617
- code: MiniappErrorCode.APP_NOT_COMPATIBLE,
6618
- message: `${target} is not compatible with the connected glasses`,
6619
- })
6620
- islandNotifications.emit({
6621
- kind: "version_incompatible",
6622
- packageName: target,
6623
- reason: `${target} is not compatible with the connected glasses`,
6624
- metadata: {missingRequired: app.compatibility.missingRequired ?? [], via: "miniapps.invoke"},
6625
- timestamp: Date.now(),
6626
- })
6627
- return
6628
- }
6629
- // Declared-action gate: the target must declare the action in its manifest.
6630
- const declared = (app.actions ?? []).find((a) => a.id === actionId)
6631
- if (!declared) {
6632
- this.sendResult(callerPackageName, requestId, false, undefined, {
6633
- code: MiniappErrorCode.ACTION_NOT_FOUND,
6634
- message: `${target} does not declare action "${actionId}"`,
6635
- })
6636
- return
6637
- }
6638
- // Visibility gate: declared actions are public by default; ones marked
6639
- // `visibility: "system"` stay restricted to system/dev callers.
6640
- if (declared.visibility === "system" && !this.isSystemPackage(callerPackageName)) {
6641
- this.auditInterop({
6642
- caller: callerPackageName,
6643
- op: "invoke",
6644
- target,
6645
- actionId,
6646
- ok: false,
6647
- errorCode: MiniappErrorCode.NOT_PERMITTED,
6648
- })
6649
- this.sendResult(callerPackageName, requestId, false, undefined, {
6650
- code: MiniappErrorCode.NOT_PERMITTED,
6651
- message: `Action "${actionId}" on ${target} is restricted to system apps`,
6652
- })
6653
- return
6680
+ const denied = checkActionAccess(app, target, actionId, callerIsSystem)
6681
+ if (denied) {
6682
+ if (denied.code === MiniappErrorCode.APP_NOT_COMPATIBLE && target && app) {
6683
+ islandNotifications.emit({
6684
+ kind: "version_incompatible",
6685
+ packageName: target,
6686
+ reason: denied.message,
6687
+ metadata: {missingRequired: app.compatibility?.missingRequired ?? [], via: "miniapps.invoke"},
6688
+ timestamp: Date.now(),
6689
+ })
6690
+ } else if (denied.code === MiniappErrorCode.NOT_PERMITTED) {
6691
+ this.auditInterop({
6692
+ caller: callerPackageName,
6693
+ op: "invoke",
6694
+ target,
6695
+ actionId,
6696
+ ok: false,
6697
+ errorCode: MiniappErrorCode.NOT_PERMITTED,
6698
+ })
6699
+ }
6700
+ return {ok: false, error: denied}
6654
6701
  }
6702
+ if (!target || !actionId) return fail(MiniappErrorCode.INTERNAL, "unreachable")
6655
6703
 
6656
6704
  // Headless wake + wait for CONNECT (idempotent / fast if already connected).
6657
6705
  try {
6658
6706
  await this.wakeInteropApp(target)
6659
6707
  } catch (e) {
6660
- this.sendResult(callerPackageName, requestId, false, undefined, {
6661
- code: MiniappErrorCode.WAKE_FAILED,
6662
- message: (e as Error)?.message ?? `failed to wake ${target}`,
6663
- })
6664
- return
6708
+ return fail(MiniappErrorCode.WAKE_FAILED, (e as Error)?.message ?? `failed to wake ${target}`)
6665
6709
  }
6666
6710
 
6667
6711
  // The wake waited for CONNECT, but the target could have dropped in the gap
6668
6712
  // before delivery — fail fast rather than arming a timer for a call that
6669
- // sendToMiniapp would silently drop (the caller would otherwise wait the
6670
- // full invoke timeout for an ACTION_CALL that was never delivered).
6713
+ // sendToMiniapp would silently drop.
6671
6714
  if (!this.connectedApps.has(target)) {
6672
- this.sendResult(callerPackageName, requestId, false, undefined, {
6673
- code: MiniappErrorCode.WAKE_FAILED,
6674
- message: `${target} disconnected before the action could be delivered`,
6675
- })
6676
6715
  this.auditInterop({
6677
6716
  caller: callerPackageName,
6678
6717
  op: "invoke",
@@ -6681,19 +6720,12 @@ class LocalMiniappRuntime {
6681
6720
  ok: false,
6682
6721
  errorCode: MiniappErrorCode.WAKE_FAILED,
6683
6722
  })
6684
- return
6723
+ return fail(MiniappErrorCode.WAKE_FAILED, `${target} disconnected before the action could be delivered`)
6685
6724
  }
6686
6725
 
6687
- // Deliver the call and arm the handler timeout; ACTION_RESULT resolves it.
6726
+ // Deliver the call and arm the handler timeout; ACTION_RESULT settles it.
6688
6727
  const callId = `act-${Date.now().toString(36)}-${this.actionCallSeq++}`
6689
- const timer = BgTimer.setTimeout(() => {
6690
- this.actionCalls.delete(callId)
6691
- this.sendResult(callerPackageName, requestId, false, undefined, {
6692
- code: MiniappErrorCode.ACTION_TIMEOUT,
6693
- message: `action "${actionId}" timed out after ${timeoutMs}ms`,
6694
- })
6695
- }, timeoutMs)
6696
- this.actionCalls.set(callId, {callerPackageName, targetPackageName: target, callerRequestId: requestId, timer})
6728
+ const outcome = this.actionCalls.register(callId, target, actionId, timeoutMs)
6697
6729
  this.sendToMiniapp(target, {
6698
6730
  type: MiniappResponseType.ACTION_CALL,
6699
6731
  callId,
@@ -6702,38 +6734,12 @@ class LocalMiniappRuntime {
6702
6734
  callerPackageName,
6703
6735
  })
6704
6736
  this.auditInterop({caller: callerPackageName, op: "invoke", target, actionId, ok: true})
6737
+ return outcome
6705
6738
  }
6706
6739
 
6707
- /** Target → host: forward an ACTION_RESULT back to the caller's pending invoke. */
6740
+ /** Target → host: settle the pending invoke for this ACTION_RESULT. */
6708
6741
  private handleActionResult(targetPackageName: string, payload: Record<string, unknown>): void {
6709
- const callId = payload.callId as string | undefined
6710
- if (!callId) return
6711
- const pending = this.actionCalls.get(callId)
6712
- if (!pending) return
6713
- // Only the invoked target may resolve this call. callIds are guessable
6714
- // (timestamp + seq), so a different connected miniapp must not be able to
6715
- // spoof a result/error for someone else's invoke.
6716
- if (pending.targetPackageName !== targetPackageName) return
6717
- this.actionCalls.delete(callId)
6718
- BgTimer.clearTimeout(pending.timer)
6719
-
6720
- if (payload.ok === true) {
6721
- const result = payload.result
6722
- if (this.actionPayloadTooLarge(result)) {
6723
- this.sendResult(pending.callerPackageName, pending.callerRequestId, false, undefined, {
6724
- code: MiniappErrorCode.PAYLOAD_TOO_LARGE,
6725
- message: "action result exceeded the 256 KB cap",
6726
- })
6727
- return
6728
- }
6729
- this.sendResult(pending.callerPackageName, pending.callerRequestId, true, result ?? null)
6730
- } else {
6731
- const error = (payload.error as ({code: string; message: string} & Record<string, unknown>) | undefined) ?? {
6732
- code: MiniappErrorCode.INTERNAL,
6733
- message: "action handler error",
6734
- }
6735
- this.sendResult(pending.callerPackageName, pending.callerRequestId, false, undefined, error)
6736
- }
6742
+ this.actionCalls.handleResult(targetPackageName, payload)
6737
6743
  }
6738
6744
 
6739
6745
  private sendResult(
@@ -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 {
@@ -66,7 +66,9 @@ const MAX_SETTLE_MS = 255 * 33
66
66
  function settleTimeOf(elements: FrameElement[]): number {
67
67
  let ms = 0
68
68
  for (const el of elements) {
69
- if (el.change !== "updated" && el.change !== "moved") continue
69
+ // Entering and leaving elements tween too, from or to their offset.
70
+ const edge = el.transition?.exit !== undefined || (el.change === "created" && el.transition?.enter !== undefined)
71
+ if (el.change !== "updated" && el.change !== "moved" && !edge) continue
70
72
  const t = el.transition?.durationMs ?? 0
71
73
  // Rotation has its own millisecond clock, beyond the geometry frame limit.
72
74
  const duration = Math.min(t, el.rotation !== undefined ? 65535 : MAX_SETTLE_MS)
@@ -0,0 +1,105 @@
1
+ import {MiniappErrorCode} from "@glassly/miniapp"
2
+
3
+ import type {ClientApp} from "../types/applet"
4
+
5
+ export type ActionError = {code: string; message: string} & Record<string, unknown>
6
+ export type ActionOutcome = {ok: true; data: unknown} | {ok: false; error: ActionError}
7
+
8
+ export const actionFailure = (code: string, message: string): ActionOutcome => ({ok: false, error: {code, message}})
9
+
10
+ /** Installed, compatible, and declares the action. */
11
+ export function appDeclaresAction(app: ClientApp | undefined, actionId: string): boolean {
12
+ if (!app) return false
13
+ if (app.compatibility && app.compatibility.isCompatible === false) return false
14
+ return (app.actions ?? []).some((a) => a.id === actionId)
15
+ }
16
+
17
+ /**
18
+ * Gates shared by miniapp->miniapp invoke and host invoke. Declared actions are
19
+ * public by default; `visibility: "system"` ones need a system caller.
20
+ */
21
+ export function checkActionAccess(
22
+ app: ClientApp | undefined,
23
+ target: string | undefined,
24
+ actionId: string | undefined,
25
+ callerIsSystem: boolean,
26
+ ): ActionError | null {
27
+ if (!target || !app) {
28
+ return {
29
+ code: MiniappErrorCode.APP_NOT_FOUND,
30
+ message: `Miniapp not found: ${target ?? "(missing targetPackageName)"}`,
31
+ }
32
+ }
33
+ if (!actionId) return {code: MiniappErrorCode.ACTION_NOT_FOUND, message: "invoke requires an actionId"}
34
+ if (app.compatibility && app.compatibility.isCompatible === false) {
35
+ return {
36
+ code: MiniappErrorCode.APP_NOT_COMPATIBLE,
37
+ message: `${target} is not compatible with the connected glasses`,
38
+ }
39
+ }
40
+ const declared = (app.actions ?? []).find((a) => a.id === actionId)
41
+ if (!declared) {
42
+ return {code: MiniappErrorCode.ACTION_NOT_FOUND, message: `${target} does not declare action "${actionId}"`}
43
+ }
44
+ if (declared.visibility === "system" && !callerIsSystem) {
45
+ return {
46
+ code: MiniappErrorCode.NOT_PERMITTED,
47
+ message: `Action "${actionId}" on ${target} is restricted to system apps`,
48
+ }
49
+ }
50
+ return null
51
+ }
52
+
53
+ /** BgTimer's shape; passed in so this module stays loadable without the native timer. */
54
+ export interface ActionTimers {
55
+ setTimeout: (cb: () => void, ms: number) => number
56
+ clearTimeout: (id: number) => void
57
+ }
58
+
59
+ /** Outstanding ACTION_CALLs awaiting an ACTION_RESULT, keyed by host-generated callId. */
60
+ export class PendingActionCalls {
61
+ private readonly calls = new Map<string, {target: string; settle: (o: ActionOutcome) => void; timer: number}>()
62
+
63
+ constructor(
64
+ private readonly timers: ActionTimers,
65
+ private readonly payloadTooLarge: (value: unknown) => boolean,
66
+ ) {}
67
+
68
+ /** Register a call; resolves with its outcome, or ACTION_TIMEOUT. */
69
+ register(callId: string, target: string, actionId: string, timeoutMs: number): Promise<ActionOutcome> {
70
+ return new Promise((resolve) => {
71
+ const timer = this.timers.setTimeout(() => {
72
+ this.calls.delete(callId)
73
+ resolve(actionFailure(MiniappErrorCode.ACTION_TIMEOUT, `action "${actionId}" timed out after ${timeoutMs}ms`))
74
+ }, timeoutMs)
75
+ this.calls.set(callId, {target, settle: resolve, timer})
76
+ })
77
+ }
78
+
79
+ /** Settle from a target's ACTION_RESULT payload. Only the invoked target may resolve a call. */
80
+ handleResult(targetPackageName: string, payload: Record<string, unknown>): void {
81
+ const callId = payload.callId as string | undefined
82
+ if (!callId) return
83
+ const pending = this.calls.get(callId)
84
+ // callIds are guessable (timestamp + seq): a different miniapp must not spoof someone else's result.
85
+ if (!pending || pending.target !== targetPackageName) return
86
+ this.calls.delete(callId)
87
+ this.timers.clearTimeout(pending.timer)
88
+
89
+ if (payload.ok === true) {
90
+ if (this.payloadTooLarge(payload.result)) {
91
+ pending.settle(actionFailure(MiniappErrorCode.PAYLOAD_TOO_LARGE, "action result exceeded the 256 KB cap"))
92
+ return
93
+ }
94
+ pending.settle({ok: true, data: payload.result ?? null})
95
+ return
96
+ }
97
+ pending.settle({
98
+ ok: false,
99
+ error: (payload.error as ActionError | undefined) ?? {
100
+ code: MiniappErrorCode.INTERNAL,
101
+ message: "action handler error",
102
+ },
103
+ })
104
+ }
105
+ }