@glassly/engine 0.3.4-dev.704 → 0.3.5-dev.705

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 (47) hide show
  1. package/build/generated/releaseMetadata.js +4 -4
  2. package/build/generated/releaseMetadata.js.map +1 -1
  3. package/build/index.d.ts +1 -1
  4. package/build/index.d.ts.map +1 -1
  5. package/build/index.js +1 -1
  6. package/build/index.js.map +1 -1
  7. package/build/services/AppRegistry.d.ts +3 -1
  8. package/build/services/AppRegistry.d.ts.map +1 -1
  9. package/build/services/AppRegistry.js +5 -2
  10. package/build/services/AppRegistry.js.map +1 -1
  11. package/build/services/LocalMiniappRuntime.d.ts +32 -0
  12. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  13. package/build/services/LocalMiniappRuntime.js +332 -1
  14. package/build/services/LocalMiniappRuntime.js.map +1 -1
  15. package/build/services/MiniappLauncher.d.ts.map +1 -1
  16. package/build/services/MiniappLauncher.js +2 -0
  17. package/build/services/MiniappLauncher.js.map +1 -1
  18. package/build/services/MiniappWidgetMode.d.ts +24 -0
  19. package/build/services/MiniappWidgetMode.d.ts.map +1 -0
  20. package/build/services/MiniappWidgetMode.js +58 -0
  21. package/build/services/MiniappWidgetMode.js.map +1 -0
  22. package/build/services/manifestActions.d.ts +8 -1
  23. package/build/services/manifestActions.d.ts.map +1 -1
  24. package/build/services/manifestActions.js +29 -0
  25. package/build/services/manifestActions.js.map +1 -1
  26. package/build/services/widgetPlacements.d.ts +43 -0
  27. package/build/services/widgetPlacements.d.ts.map +1 -0
  28. package/build/services/widgetPlacements.js +83 -0
  29. package/build/services/widgetPlacements.js.map +1 -0
  30. package/build/stores/apps.d.ts +3 -1
  31. package/build/stores/apps.d.ts.map +1 -1
  32. package/build/stores/apps.js +37 -11
  33. package/build/stores/apps.js.map +1 -1
  34. package/build/types/applet.d.ts +16 -0
  35. package/build/types/applet.d.ts.map +1 -1
  36. package/build/types/applet.js.map +1 -1
  37. package/package.json +7 -7
  38. package/src/generated/releaseMetadata.ts +4 -4
  39. package/src/index.ts +1 -0
  40. package/src/services/AppRegistry.ts +8 -2
  41. package/src/services/LocalMiniappRuntime.ts +362 -2
  42. package/src/services/MiniappLauncher.ts +3 -0
  43. package/src/services/MiniappWidgetMode.ts +62 -0
  44. package/src/services/manifestActions.ts +29 -1
  45. package/src/services/widgetPlacements.ts +121 -0
  46. package/src/stores/apps.ts +45 -11
  47. package/src/types/applet.ts +18 -0
@@ -93,6 +93,23 @@ import {
93
93
  type DerivedSettingsKey,
94
94
  } from "./settingsBridge"
95
95
  import {mayUsePermission} from "./miniappPermissions"
96
+ import {miniappRunningRegistry} from "./MiniappRunningRegistry"
97
+ import {miniappWidgetMode} from "./MiniappWidgetMode"
98
+ import {
99
+ dropHost as dropWidgetHost,
100
+ dropProvider as dropWidgetProvider,
101
+ emptyWidgetPlacements,
102
+ frameOf as widgetFrameOf,
103
+ hostsOf as widgetHostsOf,
104
+ isProviderPlaced,
105
+ placedProviders,
106
+ placementOf,
107
+ placeWidget,
108
+ setFrame as setWidgetFrame,
109
+ unplaceWidget,
110
+ type WidgetBox,
111
+ type WidgetPlacements,
112
+ } from "./widgetPlacements"
96
113
  import {
97
114
  activeDashboard,
98
115
  addDashboardApp,
@@ -149,7 +166,7 @@ import {toLanguageHint} from "@glassly/cloud-protocol/languages"
149
166
  import {buildMiniappManifestSnapshot, type MiniappRuntimeDiagnosticSnapshot} from "../utils/miniappDiagnostics"
150
167
  import ttsModelManager from "./TTSModelManager"
151
168
  import {NavigationHandlers} from "./NavigationHandlers"
152
- import type {ClientApp} from "../types/applet"
169
+ import type {ClientApp, DeclaredWidget} from "../types/applet"
153
170
  import {useAppStatusStore} from "../stores/apps"
154
171
  import {isSystemMiniapp} from "../stores/appVisibility"
155
172
  import {getDevAppAttestation, getDevAppSourcePackage} from "./AppRegistry"
@@ -178,6 +195,8 @@ export interface InstalledMiniappManifest {
178
195
  permissions?: Array<{type: string; required?: boolean; description?: string}>
179
196
  hardwareRequirements?: Array<{type: string; level: string; description?: string}>
180
197
  actions?: Array<{id?: unknown; description?: unknown; parameters?: unknown; outputSchema?: unknown}>
198
+ /** Manifest `widgets`, normalized by the runtime when a host asks. */
199
+ widgets?: unknown
181
200
  }
182
201
 
183
202
  type SpeakerStateValue = "idle" | "loading" | "playing" | "stopped" | "error"
@@ -382,6 +401,7 @@ const PERMISSION_TYPE_TO_CANONICAL: Record<string, string> = {
382
401
  SPEAKERS: "speakers",
383
402
  MANAGE_MINIAPPS: "manageMiniapps",
384
403
  PLATFORM_SETTINGS: "platformSettings",
404
+ HOST_WIDGETS: "hostWidgets",
385
405
  }
386
406
 
387
407
  /**
@@ -501,6 +521,7 @@ const ALL_CANONICAL_PERMISSIONS = [
501
521
  "displayMirror",
502
522
  "manageMiniapps",
503
523
  "platformSettings",
524
+ "hostWidgets",
504
525
  ] as const
505
526
 
506
527
  /**
@@ -1321,6 +1342,9 @@ class LocalMiniappRuntime {
1321
1342
  // core app's saved frame).
1322
1343
  localDisplayManager.onUnmount(packageName)
1323
1344
 
1345
+ // As a widget host its placements go; as a provider its boxes are forgotten.
1346
+ this.onWidgetPartyGone(packageName)
1347
+
1324
1348
  this.connectedApps.delete(packageName)
1325
1349
  this.recomputeMicRequirements()
1326
1350
  if (releasedMicGateOverride) {
@@ -1755,6 +1779,24 @@ class LocalMiniappRuntime {
1755
1779
  case MiniappRequestType.ACTION_RESULT:
1756
1780
  this.handleActionResult(packageName, payload)
1757
1781
  break
1782
+ case MiniappRequestType.WIDGETS_LIST:
1783
+ this.handleWidgetsList(packageName, requestId)
1784
+ break
1785
+ case MiniappRequestType.WIDGET_PLACE:
1786
+ void this.handleWidgetPlace(packageName, payload, requestId)
1787
+ break
1788
+ case MiniappRequestType.WIDGET_UNPLACE:
1789
+ void this.handleWidgetUnplace(packageName, payload, requestId)
1790
+ break
1791
+ case MiniappRequestType.WIDGET_SEND:
1792
+ void this.handleWidgetSend(packageName, payload, requestId)
1793
+ break
1794
+ case MiniappRequestType.WIDGET_UPDATE:
1795
+ this.handleWidgetUpdate(packageName, payload)
1796
+ break
1797
+ case MiniappRequestType.WIDGET_RESULT:
1798
+ this.handleWidgetResult(packageName, payload)
1799
+ break
1758
1800
  case MiniappRequestType.SYSTEM_EXIT:
1759
1801
  void this.handleSystemExit(packageName, requestId)
1760
1802
  break
@@ -2117,7 +2159,9 @@ class LocalMiniappRuntime {
2117
2159
 
2118
2160
  /** The app holding `gesture` (the most recent claimer), or null when it is unclaimed. */
2119
2161
  public gestureClaimant(gesture: string): string | null {
2120
- return gestureClaimant(this.gestureClaims, gesture)
2162
+ const claimant = gestureClaimant(this.gestureClaims, gesture)
2163
+ // A widget-only app hears no touch input; its claim waits until the wearer opens it.
2164
+ return claimant !== null && miniappWidgetMode.isWidgetOnly(claimant) ? null : claimant
2121
2165
  }
2122
2166
 
2123
2167
  /** Has this app registered the gesture, even if a later claimant currently receives it? */
@@ -2401,6 +2445,10 @@ class LocalMiniappRuntime {
2401
2445
  console.log(`${LOG_TAG}: dropping display from unmounted app ${packageName}`)
2402
2446
  return
2403
2447
  }
2448
+ if (miniappWidgetMode.isWidgetOnly(packageName)) {
2449
+ this.sendResult(packageName, requestId, true)
2450
+ return
2451
+ }
2404
2452
  if (!payload.layout || typeof payload.layout !== "object") {
2405
2453
  this.sendResult(packageName, requestId, false, undefined, {
2406
2454
  code: MiniappErrorCode.INTERNAL,
@@ -2441,6 +2489,11 @@ class LocalMiniappRuntime {
2441
2489
  this.sendResult(packageName, requestId, true, {status: "blocked", reason: "app stopped"})
2442
2490
  return
2443
2491
  }
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
+ }
2444
2497
  if (!Array.isArray(payload.elements)) {
2445
2498
  this.sendResult(packageName, requestId, false, undefined, {
2446
2499
  code: MiniappErrorCode.INTERNAL,
@@ -5704,6 +5757,8 @@ class LocalMiniappRuntime {
5704
5757
 
5705
5758
  for (const packageName of matchedSubs) {
5706
5759
  if (only !== undefined && packageName !== only) continue
5760
+ // The wearer's touches reach a widget-only app through its host's widget events alone.
5761
+ if (normalizedStream === MiniappStreamType.TOUCH_EVENT && miniappWidgetMode.isWidgetOnly(packageName)) continue
5707
5762
  let transcriptionRoute: TranscriptionDeliveryRoute | undefined
5708
5763
  if (normalizedStream.startsWith("transcription:") && transcriptionSource) {
5709
5764
  const routes = this.appTranscriptionRoutesForEvent(packageName, normalizedStream)
@@ -6198,6 +6253,311 @@ class LocalMiniappRuntime {
6198
6253
  }
6199
6254
  }
6200
6255
 
6256
+ // ---------------------------------------------------------------------------
6257
+ // Dashboard widgets (session.widgets): a provider serves widgets it declares,
6258
+ // a host with HOST_WIDGETS places them. The provider runs in widget mode
6259
+ // while anything of it is placed and stops once nothing is.
6260
+ // ---------------------------------------------------------------------------
6261
+
6262
+ private widgetPlacements: WidgetPlacements = emptyWidgetPlacements()
6263
+ /** Outstanding widget event calls, keyed by host-generated callId. */
6264
+ private widgetCalls = new Map<
6265
+ string,
6266
+ {hostPackageName: string; providerPackageName: string; hostRequestId?: string; timer: number}
6267
+ >()
6268
+ private widgetCallSeq = 0
6269
+ /** Elements per widget frame; a card is small, and the link is slow. */
6270
+ private static readonly WIDGET_FRAME_ELEMENT_CAP = 64
6271
+
6272
+ private requireWidgetHost(packageName: string, requestId: string | undefined, op: string): boolean {
6273
+ return this.requirePermission(packageName, requestId, "HOST_WIDGETS", `to ${op} widgets`)
6274
+ }
6275
+
6276
+ private declaredWidget(app: ClientApp, widgetId: string): DeclaredWidget | undefined {
6277
+ return (app.widgets ?? []).find((widget) => widget.id === widgetId)
6278
+ }
6279
+
6280
+ /** The target app and its declared widget, or a reply to the host saying why not. */
6281
+ private widgetTarget(
6282
+ hostPackageName: string,
6283
+ payload: Record<string, unknown>,
6284
+ requestId: string | undefined,
6285
+ ): {app: ClientApp; widgetId: string} | null {
6286
+ const target = payload.packageName as string | undefined
6287
+ const widgetId = payload.widgetId as string | undefined
6288
+ const app = target ? this.interopApps().find((a) => a.packageName === target) : undefined
6289
+ if (!target || !app) {
6290
+ this.sendResult(hostPackageName, requestId, false, undefined, {
6291
+ code: MiniappErrorCode.APP_NOT_FOUND,
6292
+ message: `Miniapp not found: ${target ?? "(missing packageName)"}`,
6293
+ })
6294
+ return null
6295
+ }
6296
+ if (!widgetId || !this.declaredWidget(app, widgetId)) {
6297
+ this.sendResult(hostPackageName, requestId, false, undefined, {
6298
+ code: MiniappErrorCode.WIDGET_NOT_FOUND,
6299
+ message: `${target} does not declare widget "${widgetId ?? "(missing widgetId)"}"`,
6300
+ })
6301
+ return null
6302
+ }
6303
+ return {app, widgetId}
6304
+ }
6305
+
6306
+ private handleWidgetsList(hostPackageName: string, requestId?: string): void {
6307
+ if (!this.requireWidgetHost(hostPackageName, requestId, "list")) return
6308
+ const rows: Record<string, unknown>[] = []
6309
+ for (const app of this.interopApps()) {
6310
+ if (!app.packageName || app.packageName === hostPackageName || app.packageName.includes("@empty")) continue
6311
+ for (const widget of app.widgets ?? []) {
6312
+ rows.push({
6313
+ packageName: app.packageName,
6314
+ appName: app.name,
6315
+ id: widget.id,
6316
+ name: widget.name,
6317
+ sizes: widget.sizes,
6318
+ defaultSize: widget.defaultSize,
6319
+ running: app.running,
6320
+ compatible: app.compatibility?.isCompatible !== false,
6321
+ })
6322
+ }
6323
+ }
6324
+ this.sendResult(hostPackageName, requestId, true, rows)
6325
+ }
6326
+
6327
+ private parseWidgetBox(raw: unknown): WidgetBox | null {
6328
+ const box = raw as {size?: unknown; width?: unknown; height?: unknown} | null
6329
+ if (!box || typeof box !== "object") return null
6330
+ if (box.size !== "XS" && box.size !== "SM" && box.size !== "MD" && box.size !== "LG") return null
6331
+ const width = Number(box.width)
6332
+ const height = Number(box.height)
6333
+ if (!(width > 0) || !(height > 0)) return null
6334
+ return {size: box.size, width: Math.round(width), height: Math.round(height)}
6335
+ }
6336
+
6337
+ private sendWidgetCall(
6338
+ providerPackageName: string,
6339
+ widgetId: string,
6340
+ call: {kind: "box"; box: WidgetBox | null} | {kind: "event"; event: Record<string, unknown>},
6341
+ ): string {
6342
+ const callId = `wdg-${Date.now().toString(36)}-${this.widgetCallSeq++}`
6343
+ this.sendToMiniapp(providerPackageName, {
6344
+ type: MiniappResponseType.WIDGET_CALL,
6345
+ callId,
6346
+ widgetId,
6347
+ kind: call.kind,
6348
+ ...(call.kind === "box" ? {box: call.box} : {event: call.event}),
6349
+ })
6350
+ return callId
6351
+ }
6352
+
6353
+ /**
6354
+ * session.widgets.place: record the host's box, wake the provider in widget
6355
+ * mode when it is not running, hand it the box, and answer with the last
6356
+ * frame the provider pushed for the widget.
6357
+ */
6358
+ private async handleWidgetPlace(
6359
+ hostPackageName: string,
6360
+ payload: Record<string, unknown>,
6361
+ requestId?: string,
6362
+ ): Promise<void> {
6363
+ if (!this.requireWidgetHost(hostPackageName, requestId, "place")) return
6364
+ const target = this.widgetTarget(hostPackageName, payload, requestId)
6365
+ if (!target) return
6366
+ const {app, widgetId} = target
6367
+ const box = this.parseWidgetBox(payload.box)
6368
+ if (!box) {
6369
+ this.sendResult(hostPackageName, requestId, false, undefined, {
6370
+ code: MiniappErrorCode.INVALID_ARGUMENT,
6371
+ message: "place needs a box {size, width, height}",
6372
+ })
6373
+ return
6374
+ }
6375
+ if (app.compatibility && app.compatibility.isCompatible === false) {
6376
+ this.sendResult(hostPackageName, requestId, false, undefined, {
6377
+ code: MiniappErrorCode.APP_NOT_COMPATIBLE,
6378
+ message: `${app.packageName} is not compatible with the connected glasses`,
6379
+ })
6380
+ return
6381
+ }
6382
+ const provider = app.packageName
6383
+ // Record the placement before the wake so the spawn lands in widget mode
6384
+ // and an unplace racing it cannot stop the provider early.
6385
+ this.widgetPlacements = placeWidget(this.widgetPlacements, {hostPackageName, providerPackageName: provider, widgetId, box})
6386
+ miniappWidgetMode.setPlaced(provider, true)
6387
+ if (!miniappRunningRegistry.has(provider)) miniappWidgetMode.setWidgetOnly(provider, true)
6388
+ try {
6389
+ await this.wakeInteropApp(provider)
6390
+ } catch (e) {
6391
+ this.widgetPlacements = unplaceWidget(this.widgetPlacements, hostPackageName, provider, widgetId)
6392
+ void this.settleWidgetProvider(provider)
6393
+ this.sendResult(hostPackageName, requestId, false, undefined, {
6394
+ code: MiniappErrorCode.WAKE_FAILED,
6395
+ message: (e as Error)?.message ?? `failed to wake ${provider}`,
6396
+ })
6397
+ return
6398
+ }
6399
+ // The host may have unplaced it during the wake.
6400
+ if (placementOf(this.widgetPlacements, hostPackageName, provider, widgetId) && this.connectedApps.has(provider)) {
6401
+ this.sendWidgetCall(provider, widgetId, {kind: "box", box})
6402
+ }
6403
+ this.sendResult(hostPackageName, requestId, true, {frame: widgetFrameOf(this.widgetPlacements, provider, widgetId)})
6404
+ }
6405
+
6406
+ private async handleWidgetUnplace(
6407
+ hostPackageName: string,
6408
+ payload: Record<string, unknown>,
6409
+ requestId?: string,
6410
+ ): Promise<void> {
6411
+ if (!this.requireWidgetHost(hostPackageName, requestId, "unplace")) return
6412
+ const target = this.widgetTarget(hostPackageName, payload, requestId)
6413
+ if (!target) return
6414
+ const provider = target.app.packageName
6415
+ const had = placementOf(this.widgetPlacements, hostPackageName, provider, target.widgetId)
6416
+ this.widgetPlacements = unplaceWidget(this.widgetPlacements, hostPackageName, provider, target.widgetId)
6417
+ this.sendResult(hostPackageName, requestId, true)
6418
+ if (!had) return
6419
+ if (this.connectedApps.has(provider) && widgetHostsOf(this.widgetPlacements, provider, target.widgetId).length === 0) {
6420
+ this.sendWidgetCall(provider, target.widgetId, {kind: "box", box: null})
6421
+ }
6422
+ await this.settleWidgetProvider(provider)
6423
+ }
6424
+
6425
+ /** A provider with nothing placed and no wearer-opened run of its own stops. */
6426
+ private async settleWidgetProvider(providerPackageName: string): Promise<void> {
6427
+ if (isProviderPlaced(this.widgetPlacements, providerPackageName)) return
6428
+ miniappWidgetMode.setPlaced(providerPackageName, false)
6429
+ if (!miniappWidgetMode.isWidgetOnly(providerPackageName)) return
6430
+ // Widget mode stays set until the context is gone, so the tray never
6431
+ // sees the stopping app as a normal one.
6432
+ try {
6433
+ await this.stopInteropApp(providerPackageName)
6434
+ } catch (err) {
6435
+ console.warn(`${LOG_TAG}: stopping widget provider ${providerPackageName} failed`, err)
6436
+ }
6437
+ }
6438
+
6439
+ /** session.widgets.send: forward input to a placed widget and relay the provider's answer. */
6440
+ private async handleWidgetSend(
6441
+ hostPackageName: string,
6442
+ payload: Record<string, unknown>,
6443
+ requestId?: string,
6444
+ ): Promise<void> {
6445
+ if (!this.requireWidgetHost(hostPackageName, requestId, "send")) return
6446
+ const target = this.widgetTarget(hostPackageName, payload, requestId)
6447
+ if (!target) return
6448
+ const provider = target.app.packageName
6449
+ const event = payload.event as Record<string, unknown> | undefined
6450
+ if (!event || typeof event !== "object" || typeof event.type !== "string") {
6451
+ this.sendResult(hostPackageName, requestId, false, undefined, {
6452
+ code: MiniappErrorCode.INVALID_ARGUMENT,
6453
+ message: "send needs an event with a string type",
6454
+ })
6455
+ return
6456
+ }
6457
+ if (!placementOf(this.widgetPlacements, hostPackageName, provider, target.widgetId)) {
6458
+ this.sendResult(hostPackageName, requestId, false, undefined, {
6459
+ code: MiniappErrorCode.WIDGET_NOT_PLACED,
6460
+ message: `${hostPackageName} has not placed ${provider}/${target.widgetId}`,
6461
+ })
6462
+ return
6463
+ }
6464
+ // Floored at 6 s for the same reason as actions: a provider just woken
6465
+ // buffers the call for 5 s while it registers.
6466
+ const timeoutMs = Math.min(Math.max(Number(payload.timeoutMs) || 5_000, 6_000), 30_000)
6467
+ try {
6468
+ await this.wakeInteropApp(provider)
6469
+ } catch (e) {
6470
+ this.sendResult(hostPackageName, requestId, false, undefined, {
6471
+ code: MiniappErrorCode.WAKE_FAILED,
6472
+ message: (e as Error)?.message ?? `failed to wake ${provider}`,
6473
+ })
6474
+ return
6475
+ }
6476
+ if (!this.connectedApps.has(provider)) {
6477
+ this.sendResult(hostPackageName, requestId, false, undefined, {
6478
+ code: MiniappErrorCode.WAKE_FAILED,
6479
+ message: `${provider} disconnected before the event could be delivered`,
6480
+ })
6481
+ return
6482
+ }
6483
+ const callId = this.sendWidgetCall(provider, target.widgetId, {kind: "event", event})
6484
+ const timer = BgTimer.setTimeout(() => {
6485
+ this.widgetCalls.delete(callId)
6486
+ this.sendResult(hostPackageName, requestId, false, undefined, {
6487
+ code: MiniappErrorCode.ACTION_TIMEOUT,
6488
+ message: `widget event "${event.type}" timed out after ${timeoutMs}ms`,
6489
+ })
6490
+ }, timeoutMs)
6491
+ this.widgetCalls.set(callId, {hostPackageName, providerPackageName: provider, hostRequestId: requestId, timer})
6492
+ }
6493
+
6494
+ /** Provider → host: a WIDGET_RESULT for a pending send; box acknowledgements have no pending call and are dropped. */
6495
+ private handleWidgetResult(providerPackageName: string, payload: Record<string, unknown>): void {
6496
+ const callId = payload.callId as string | undefined
6497
+ if (!callId) return
6498
+ const pending = this.widgetCalls.get(callId)
6499
+ if (!pending || pending.providerPackageName !== providerPackageName) return
6500
+ this.widgetCalls.delete(callId)
6501
+ BgTimer.clearTimeout(pending.timer)
6502
+ if (payload.ok === true) {
6503
+ const result = payload.result
6504
+ if (this.actionPayloadTooLarge(result)) {
6505
+ this.sendResult(pending.hostPackageName, pending.hostRequestId, false, undefined, {
6506
+ code: MiniappErrorCode.PAYLOAD_TOO_LARGE,
6507
+ message: "widget event result exceeded the 256 KB cap",
6508
+ })
6509
+ return
6510
+ }
6511
+ this.sendResult(pending.hostPackageName, pending.hostRequestId, true, result ?? null)
6512
+ } else {
6513
+ const error = (payload.error as ({code: string; message: string} & Record<string, unknown>) | undefined) ?? {
6514
+ code: MiniappErrorCode.INTERNAL,
6515
+ message: "widget event handler error",
6516
+ }
6517
+ this.sendResult(pending.hostPackageName, pending.hostRequestId, false, undefined, error)
6518
+ }
6519
+ }
6520
+
6521
+ /** Provider → host, one-shot: cache the frame and push it to every host holding the widget. */
6522
+ private handleWidgetUpdate(providerPackageName: string, payload: Record<string, unknown>): void {
6523
+ const widgetId = payload.widgetId as string | undefined
6524
+ const elements = payload.elements
6525
+ if (!widgetId || !Array.isArray(elements)) return
6526
+ const app = this.interopApps().find((a) => a.packageName === providerPackageName)
6527
+ if (!app || !this.declaredWidget(app, widgetId)) return
6528
+ if (elements.length > LocalMiniappRuntime.WIDGET_FRAME_ELEMENT_CAP || this.actionPayloadTooLarge(elements)) {
6529
+ console.warn(`${LOG_TAG}: dropping oversized widget frame from ${providerPackageName}/${widgetId}`)
6530
+ return
6531
+ }
6532
+ this.widgetPlacements = setWidgetFrame(this.widgetPlacements, providerPackageName, widgetId, elements)
6533
+ for (const host of widgetHostsOf(this.widgetPlacements, providerPackageName, widgetId)) {
6534
+ this.sendToMiniapp(host, {type: MiniappResponseType.WIDGET_FRAME, packageName: providerPackageName, widgetId, elements})
6535
+ }
6536
+ }
6537
+
6538
+ /** A miniapp disconnected: as a host its placements go, as a provider its boxes are forgotten and its frames kept. */
6539
+ private onWidgetPartyGone(packageName: string): void {
6540
+ const providersBefore = placedProviders(this.widgetPlacements)
6541
+ this.widgetPlacements = dropWidgetHost(this.widgetPlacements, packageName)
6542
+ this.widgetPlacements = dropWidgetProvider(this.widgetPlacements, packageName)
6543
+ miniappWidgetMode.setPlaced(packageName, false)
6544
+ miniappWidgetMode.setWidgetOnly(packageName, false)
6545
+ for (const [callId, pending] of Array.from(this.widgetCalls)) {
6546
+ if (pending.hostPackageName !== packageName && pending.providerPackageName !== packageName) continue
6547
+ this.widgetCalls.delete(callId)
6548
+ BgTimer.clearTimeout(pending.timer)
6549
+ if (pending.providerPackageName === packageName) {
6550
+ this.sendResult(pending.hostPackageName, pending.hostRequestId, false, undefined, {
6551
+ code: MiniappErrorCode.WAKE_FAILED,
6552
+ message: `${packageName} stopped before answering`,
6553
+ })
6554
+ }
6555
+ }
6556
+ for (const provider of providersBefore) {
6557
+ if (provider !== packageName) void this.settleWidgetProvider(provider)
6558
+ }
6559
+ }
6560
+
6201
6561
  /** True iff `value` serializes larger than the action payload cap. */
6202
6562
  private actionPayloadTooLarge(value: unknown): boolean {
6203
6563
  try {
@@ -136,6 +136,7 @@ class MiniappLauncher {
136
136
  permissions: manifest.permissions as InstalledMiniappManifest["permissions"],
137
137
  hardwareRequirements: manifest.hardwareRequirements as InstalledMiniappManifest["hardwareRequirements"],
138
138
  actions: manifest.actions as InstalledMiniappManifest["actions"],
139
+ widgets: manifest.widgets,
139
140
  }
140
141
 
141
142
  let bgSource: string
@@ -175,6 +176,7 @@ class MiniappLauncher {
175
176
  permissions?: Array<{type: string; required?: boolean; description?: string}>
176
177
  hardwareRequirements?: Array<{type: string; level: string; description?: string}>
177
178
  actions?: Array<{id?: unknown; description?: unknown; parameters?: unknown; outputSchema?: unknown}>
179
+ widgets?: unknown
178
180
  } | null
179
181
  const declaredPermissions = (manifest?.permissions ?? [])
180
182
  .map((p) => p.type)
@@ -191,6 +193,7 @@ class MiniappLauncher {
191
193
  permissions: manifest.permissions,
192
194
  hardwareRequirements: manifest.hardwareRequirements,
193
195
  actions: manifest.actions,
196
+ widgets: manifest.widgets,
194
197
  }
195
198
  : undefined
196
199
 
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Widget mode: a miniapp whose background context runs only because a host
3
+ * (the dashboard) placed one of its widgets. It is running, but it has no
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.
8
+ *
9
+ * The runtime is the writer (placements come and go through it); the apps
10
+ * store projects `widgetOnly` from here beside `running`.
11
+ */
12
+
13
+ type Listener = () => void
14
+
15
+ const widgetOnly = new Set<string>()
16
+ /** Providers with at least one widget placed by some host. */
17
+ const placed = new Set<string>()
18
+ const listeners = new Set<Listener>()
19
+
20
+ function notify(): void {
21
+ for (const fn of listeners) {
22
+ try {
23
+ fn()
24
+ } catch (e) {
25
+ console.warn("MiniappWidgetMode: listener threw", e)
26
+ }
27
+ }
28
+ }
29
+
30
+ export const miniappWidgetMode = {
31
+ /** Mark (or clear) widget mode. Clearing promotes: the app keeps running as a normal app. */
32
+ setWidgetOnly(packageName: string, on: boolean): void {
33
+ const changed = on ? !widgetOnly.has(packageName) : widgetOnly.delete(packageName)
34
+ if (on) widgetOnly.add(packageName)
35
+ if (changed) notify()
36
+ },
37
+
38
+ isWidgetOnly(packageName: string): boolean {
39
+ return widgetOnly.has(packageName)
40
+ },
41
+
42
+ /** Record whether any host has a widget of this provider placed. */
43
+ setPlaced(packageName: string, on: boolean): void {
44
+ if (on) placed.add(packageName)
45
+ else placed.delete(packageName)
46
+ },
47
+
48
+ isPlaced(packageName: string): boolean {
49
+ return placed.has(packageName)
50
+ },
51
+
52
+ getAll(): string[] {
53
+ return Array.from(widgetOnly)
54
+ },
55
+
56
+ subscribe(fn: Listener): () => void {
57
+ listeners.add(fn)
58
+ return () => {
59
+ listeners.delete(fn)
60
+ }
61
+ },
62
+ }
@@ -1,4 +1,4 @@
1
- import type {DeclaredAction} from "../types/applet"
1
+ import type {DeclaredAction, DeclaredWidget, DeclaredWidgetSize} from "../types/applet"
2
2
 
3
3
  /** The subset of ClientApp that action-listing needs (structural, no cycle on applet types). */
4
4
  export interface ActionListApp {
@@ -78,3 +78,31 @@ export function normalizeManifestActions(raw: unknown): DeclaredAction[] {
78
78
  }
79
79
  return out
80
80
  }
81
+
82
+ const WIDGET_SIZES: readonly DeclaredWidgetSize[] = ["XS", "SM", "MD", "LG"]
83
+ const isWidgetSize = (value: unknown): value is DeclaredWidgetSize => (WIDGET_SIZES as readonly unknown[]).includes(value)
84
+
85
+ /**
86
+ * Normalize manifest widgets for installed and dev-sideloaded bundles: an
87
+ * entry without an id, a name or a usable size is dropped, repeats of a
88
+ * size are folded, and a default size the widget does not offer becomes
89
+ * its first size.
90
+ */
91
+ export function normalizeManifestWidgets(raw: unknown): DeclaredWidget[] {
92
+ if (!Array.isArray(raw)) return []
93
+ const out: DeclaredWidget[] = []
94
+ const seen = new Set<string>()
95
+ for (const w of raw as Array<{id?: unknown; name?: unknown; sizes?: unknown; defaultSize?: unknown}>) {
96
+ if (!w || typeof w.id !== "string" || !w.id || typeof w.name !== "string" || !w.name || seen.has(w.id)) continue
97
+ const sizes = Array.isArray(w.sizes) ? Array.from(new Set(w.sizes.filter(isWidgetSize))) : []
98
+ if (sizes.length === 0) continue
99
+ seen.add(w.id)
100
+ out.push({
101
+ id: w.id,
102
+ name: w.name,
103
+ sizes,
104
+ defaultSize: isWidgetSize(w.defaultSize) && sizes.includes(w.defaultSize) ? w.defaultSize : sizes[0],
105
+ })
106
+ }
107
+ return out
108
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Who placed which widget where: the pure bookkeeping behind the runtime's
3
+ * widget routing. A placement is one host's box for one provider widget;
4
+ * a provider is placed while any host holds one of its widgets. Frames are
5
+ * kept per widget so a host that places one again gets the last frame
6
+ * at once.
7
+ */
8
+
9
+ export interface WidgetBox {
10
+ size: "XS" | "SM" | "MD" | "LG"
11
+ width: number
12
+ height: number
13
+ }
14
+
15
+ export interface Placement {
16
+ hostPackageName: string
17
+ providerPackageName: string
18
+ widgetId: string
19
+ box: WidgetBox
20
+ }
21
+
22
+ export interface WidgetPlacements {
23
+ /** Keyed by `${host}|${provider}/${widgetId}`. */
24
+ placements: ReadonlyMap<string, Placement>
25
+ /** The latest frame per `${provider}/${widgetId}`. */
26
+ frames: ReadonlyMap<string, unknown[]>
27
+ }
28
+
29
+ export const widgetKey = (providerPackageName: string, widgetId: string): string => `${providerPackageName}/${widgetId}`
30
+ const placementKey = (hostPackageName: string, providerPackageName: string, widgetId: string): string =>
31
+ `${hostPackageName}|${widgetKey(providerPackageName, widgetId)}`
32
+
33
+ export function emptyWidgetPlacements(): WidgetPlacements {
34
+ return {placements: new Map(), frames: new Map()}
35
+ }
36
+
37
+ export function placeWidget(state: WidgetPlacements, placement: Placement): WidgetPlacements {
38
+ const placements = new Map(state.placements)
39
+ placements.set(placementKey(placement.hostPackageName, placement.providerPackageName, placement.widgetId), placement)
40
+ return {...state, placements}
41
+ }
42
+
43
+ export function unplaceWidget(
44
+ state: WidgetPlacements,
45
+ hostPackageName: string,
46
+ providerPackageName: string,
47
+ widgetId: string,
48
+ ): WidgetPlacements {
49
+ const key = placementKey(hostPackageName, providerPackageName, widgetId)
50
+ if (!state.placements.has(key)) return state
51
+ const placements = new Map(state.placements)
52
+ placements.delete(key)
53
+ return {...state, placements}
54
+ }
55
+
56
+ /** A host went away: drop everything it placed. */
57
+ export function dropHost(state: WidgetPlacements, hostPackageName: string): WidgetPlacements {
58
+ const placements = new Map(state.placements)
59
+ for (const [key, placement] of state.placements) {
60
+ if (placement.hostPackageName === hostPackageName) placements.delete(key)
61
+ }
62
+ return placements.size === state.placements.size ? state : {...state, placements}
63
+ }
64
+
65
+ /** A provider went away: drop its placements, keep its frames for the placeholder. */
66
+ export function dropProvider(state: WidgetPlacements, providerPackageName: string): WidgetPlacements {
67
+ const placements = new Map(state.placements)
68
+ for (const [key, placement] of state.placements) {
69
+ if (placement.providerPackageName === providerPackageName) placements.delete(key)
70
+ }
71
+ return placements.size === state.placements.size ? state : {...state, placements}
72
+ }
73
+
74
+ export function setFrame(state: WidgetPlacements, providerPackageName: string, widgetId: string, elements: unknown[]): WidgetPlacements {
75
+ const frames = new Map(state.frames)
76
+ frames.set(widgetKey(providerPackageName, widgetId), elements)
77
+ return {...state, frames}
78
+ }
79
+
80
+ export function frameOf(state: WidgetPlacements, providerPackageName: string, widgetId: string): unknown[] | null {
81
+ return state.frames.get(widgetKey(providerPackageName, widgetId)) ?? null
82
+ }
83
+
84
+ export function placementOf(
85
+ state: WidgetPlacements,
86
+ hostPackageName: string,
87
+ providerPackageName: string,
88
+ widgetId: string,
89
+ ): Placement | null {
90
+ return state.placements.get(placementKey(hostPackageName, providerPackageName, widgetId)) ?? null
91
+ }
92
+
93
+ /** Hosts holding this widget, each once. */
94
+ export function hostsOf(state: WidgetPlacements, providerPackageName: string, widgetId: string): string[] {
95
+ const hosts = new Set<string>()
96
+ for (const placement of state.placements.values()) {
97
+ if (placement.providerPackageName === providerPackageName && placement.widgetId === widgetId) {
98
+ hosts.add(placement.hostPackageName)
99
+ }
100
+ }
101
+ return Array.from(hosts)
102
+ }
103
+
104
+ /** Providers with at least one widget placed. */
105
+ export function placedProviders(state: WidgetPlacements): Set<string> {
106
+ const providers = new Set<string>()
107
+ for (const placement of state.placements.values()) providers.add(placement.providerPackageName)
108
+ return providers
109
+ }
110
+
111
+ export function isProviderPlaced(state: WidgetPlacements, providerPackageName: string): boolean {
112
+ for (const placement of state.placements.values()) {
113
+ if (placement.providerPackageName === providerPackageName) return true
114
+ }
115
+ return false
116
+ }
117
+
118
+ /** The placements a host holds on a provider, for re-delivering boxes. */
119
+ export function placementsBy(state: WidgetPlacements, hostPackageName: string): Placement[] {
120
+ return Array.from(state.placements.values()).filter((placement) => placement.hostPackageName === hostPackageName)
121
+ }