@glassly/engine 0.3.4-dev.704 → 0.3.5-dev.706
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.
- package/build/generated/releaseMetadata.js +4 -4
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -1
- package/build/index.js.map +1 -1
- package/build/services/AppRegistry.d.ts +3 -1
- package/build/services/AppRegistry.d.ts.map +1 -1
- package/build/services/AppRegistry.js +5 -2
- package/build/services/AppRegistry.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +32 -0
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +332 -1
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MiniappLauncher.d.ts.map +1 -1
- package/build/services/MiniappLauncher.js +2 -0
- package/build/services/MiniappLauncher.js.map +1 -1
- package/build/services/MiniappWidgetMode.d.ts +24 -0
- package/build/services/MiniappWidgetMode.d.ts.map +1 -0
- package/build/services/MiniappWidgetMode.js +58 -0
- package/build/services/MiniappWidgetMode.js.map +1 -0
- package/build/services/manifestActions.d.ts +8 -1
- package/build/services/manifestActions.d.ts.map +1 -1
- package/build/services/manifestActions.js +29 -0
- package/build/services/manifestActions.js.map +1 -1
- package/build/services/widgetPlacements.d.ts +43 -0
- package/build/services/widgetPlacements.d.ts.map +1 -0
- package/build/services/widgetPlacements.js +83 -0
- package/build/services/widgetPlacements.js.map +1 -0
- package/build/stores/apps.d.ts +3 -1
- package/build/stores/apps.d.ts.map +1 -1
- package/build/stores/apps.js +37 -11
- package/build/stores/apps.js.map +1 -1
- package/build/types/applet.d.ts +16 -0
- package/build/types/applet.d.ts.map +1 -1
- package/build/types/applet.js.map +1 -1
- package/package.json +7 -7
- package/src/generated/releaseMetadata.ts +4 -4
- package/src/index.ts +1 -0
- package/src/services/AppRegistry.ts +8 -2
- package/src/services/LocalMiniappRuntime.ts +362 -2
- package/src/services/MiniappLauncher.ts +3 -0
- package/src/services/MiniappWidgetMode.ts +62 -0
- package/src/services/manifestActions.ts +29 -1
- package/src/services/widgetPlacements.ts +121 -0
- package/src/stores/apps.ts +45 -11
- 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
|
-
|
|
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
|
+
}
|