@glassly/engine 0.2.9-dev.458 → 0.3.0-beta.464

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 (75) hide show
  1. package/build/engine.d.ts +3 -0
  2. package/build/engine.d.ts.map +1 -1
  3. package/build/facades/miniapps.d.ts +6 -0
  4. package/build/facades/miniapps.d.ts.map +1 -1
  5. package/build/facades/miniapps.js +7 -0
  6. package/build/facades/miniapps.js.map +1 -1
  7. package/build/generated/releaseMetadata.js +4 -4
  8. package/build/generated/releaseMetadata.js.map +1 -1
  9. package/build/index.d.ts +2 -2
  10. package/build/index.d.ts.map +1 -1
  11. package/build/index.js +2 -2
  12. package/build/index.js.map +1 -1
  13. package/build/services/AppRegistry.js +2 -2
  14. package/build/services/AppRegistry.js.map +1 -1
  15. package/build/services/BlobStore.js +2 -2
  16. package/build/services/BlobStore.js.map +1 -1
  17. package/build/services/LocalMiniappRuntime.d.ts +22 -9
  18. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  19. package/build/services/LocalMiniappRuntime.js +104 -49
  20. package/build/services/LocalMiniappRuntime.js.map +1 -1
  21. package/build/services/MiniappLauncher.d.ts +2 -0
  22. package/build/services/MiniappLauncher.d.ts.map +1 -1
  23. package/build/services/MiniappLauncher.js +6 -1
  24. package/build/services/MiniappLauncher.js.map +1 -1
  25. package/build/services/miniappPermissions.d.ts +16 -0
  26. package/build/services/miniappPermissions.d.ts.map +1 -0
  27. package/build/services/miniappPermissions.js +7 -0
  28. package/build/services/miniappPermissions.js.map +1 -0
  29. package/build/services/pages/OverlayPages.d.ts +13 -2
  30. package/build/services/pages/OverlayPages.d.ts.map +1 -1
  31. package/build/services/pages/OverlayPages.js +11 -10
  32. package/build/services/pages/OverlayPages.js.map +1 -1
  33. package/build/services/pages/PageHost.d.ts +1 -1
  34. package/build/services/pages/PageHost.d.ts.map +1 -1
  35. package/build/services/pages/PageHost.js +2 -2
  36. package/build/services/pages/PageHost.js.map +1 -1
  37. package/build/services/pages/activeDashboard.d.ts +7 -0
  38. package/build/services/pages/activeDashboard.d.ts.map +1 -0
  39. package/build/services/pages/activeDashboard.js +21 -0
  40. package/build/services/pages/activeDashboard.js.map +1 -0
  41. package/build/services/pages/overlayApps.d.ts +20 -13
  42. package/build/services/pages/overlayApps.d.ts.map +1 -1
  43. package/build/services/pages/overlayApps.js +50 -31
  44. package/build/services/pages/overlayApps.js.map +1 -1
  45. package/build/services/settingsBridge.d.ts +15 -14
  46. package/build/services/settingsBridge.d.ts.map +1 -1
  47. package/build/services/settingsBridge.js +20 -20
  48. package/build/services/settingsBridge.js.map +1 -1
  49. package/build/stores/apps.d.ts +5 -0
  50. package/build/stores/apps.d.ts.map +1 -1
  51. package/build/stores/apps.js +40 -2
  52. package/build/stores/apps.js.map +1 -1
  53. package/build/stores/settings.d.ts.map +1 -1
  54. package/build/stores/settings.js +6 -45
  55. package/build/stores/settings.js.map +1 -1
  56. package/build/types/applet.d.ts +2 -2
  57. package/build/types/applet.d.ts.map +1 -1
  58. package/build/types/applet.js.map +1 -1
  59. package/package.json +7 -7
  60. package/src/facades/miniapps.ts +9 -0
  61. package/src/generated/releaseMetadata.ts +4 -4
  62. package/src/index.ts +3 -1
  63. package/src/services/AppRegistry.ts +2 -2
  64. package/src/services/BlobStore.ts +2 -2
  65. package/src/services/LocalMiniappRuntime.ts +124 -49
  66. package/src/services/MiniappLauncher.ts +6 -1
  67. package/src/services/miniappPermissions.ts +19 -0
  68. package/src/services/pages/OverlayPages.ts +11 -10
  69. package/src/services/pages/PageHost.ts +2 -2
  70. package/src/services/pages/activeDashboard.ts +24 -0
  71. package/src/services/pages/overlayApps.ts +51 -30
  72. package/src/services/settingsBridge.ts +20 -20
  73. package/src/stores/apps.ts +44 -2
  74. package/src/stores/settings.ts +6 -45
  75. package/src/types/applet.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassly/engine",
3
- "version": "0.2.9-dev.458",
3
+ "version": "0.3.0-beta.464",
4
4
  "description": "Glassly Engine — miniapp registry, WebView bridge, and miniapp store",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -97,17 +97,17 @@
97
97
  "registry": "https://registry.npmjs.org/"
98
98
  },
99
99
  "dependencies": {
100
- "@glassly/bluetooth-sdk": "0.2.9-dev.458",
101
- "@glassly/cloud-client": "0.2.9-dev.458",
102
- "@glassly/cloud-protocol": "0.2.9-dev.458",
103
- "@glassly/crust": "0.2.9-dev.458",
104
- "@glassly/miniapp": "0.2.9-dev.458",
100
+ "@glassly/bluetooth-sdk": "0.3.0-beta.464",
101
+ "@glassly/cloud-client": "0.3.0-beta.464",
102
+ "@glassly/cloud-protocol": "0.3.0-beta.464",
103
+ "@glassly/crust": "0.3.0-beta.464",
104
+ "@glassly/miniapp": "0.3.0-beta.464",
105
105
  "buffer": "^6.0.3",
106
106
  "events": "^3.3.0",
107
107
  "react-native-marked": "8.1.1",
108
108
  "semver": "^7.7.4",
109
109
  "typesafe-ts": "^1.7.1",
110
- "@glassly/llm-providers": "0.2.9-dev.458"
110
+ "@glassly/llm-providers": "0.3.0-beta.464"
111
111
  },
112
112
  "devDependencies": {
113
113
  "@glassly/llm-providers": "workspace:*",
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import {useAppStatusStore} from "../stores/apps"
12
12
  import localMiniappRuntime from "../services/LocalMiniappRuntime"
13
+ import {activeDashboardAmong} from "../services/pages/activeDashboard"
13
14
  import type {ClientApp} from "../types"
14
15
 
15
16
  type AppsState = ReturnType<typeof useAppStatusStore.getState>
@@ -61,4 +62,12 @@ export const miniapps = {
61
62
  useAppStatusStore.getState().setHiddenStatus(...args),
62
63
  /** Mark a miniapp Always On (starts at launch, stops only from the switcher pill), persisted for the next boot. */
63
64
  setAlwaysOn: (...args: Parameters<AppsState["setAlwaysOn"]>) => useAppStatusStore.getState().setAlwaysOn(...args),
65
+
66
+ /** Installed miniapps that can hold the dashboard role (manifest type "dashboard"). */
67
+ dashboards: (): ClientApp[] => useAppStatusStore.getState().apps.filter((app) => app.type === "dashboard"),
68
+ /** The package that holds the dashboard role, or null when no dashboard is installed. */
69
+ activeDashboard: (): string | null => activeDashboardAmong(useAppStatusStore.getState().apps),
70
+ /** Give the dashboard role to an installed dashboard; the previous holder stops and the new one starts. */
71
+ setActiveDashboard: (...args: Parameters<AppsState["setActiveDashboard"]>) =>
72
+ useAppStatusStore.getState().setActiveDashboard(...args),
64
73
  }
@@ -11,10 +11,10 @@ export interface EngineReleaseMetadata {
11
11
 
12
12
  export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
- "familyBaseVersion": "0.2.9",
15
- "releaseIdentity": "0.2.9-dev.458",
16
- "releaseSetId": "glassly-0.2.9-dev.458",
17
- "sourceCommit": "9df4bc8a0ae8775a6daf69242824f0f86805c1f2",
14
+ "familyBaseVersion": "0.3.0",
15
+ "releaseIdentity": "0.3.0-beta.464",
16
+ "releaseSetId": "glassly-0.3.0-beta.464",
17
+ "sourceCommit": "6cca1ddaaa8e8aa8acd4951a8b19283736b56e6e",
18
18
  "otaManifestUrl": null,
19
19
  "otaManifestSha256": null
20
20
  })
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ export type {
23
23
  } from "./facades/reports"
24
24
  export type {IslandNotification, IslandNotificationKind} from "./facades/notifications"
25
25
  export type {PresentationOwner} from "./services/notifications/notificationPolicy"
26
- export {DEFAULT_HIDDEN_PACKAGES} from "./stores/appVisibility"
26
+ export {DEFAULT_HIDDEN_PACKAGES, isGatedByDevTools} from "./stores/appVisibility"
27
27
  export type {WifiSearchResult} from "./facades/glassesWifi"
28
28
  export type {IdentitySnapshot} from "./services/PairingIdentity"
29
29
  export type {DisplayMirrorEvent} from "./facades/displayMirror"
@@ -182,6 +182,8 @@ export {
182
182
  useUninstall,
183
183
  useActiveApps,
184
184
  useRunningAlwaysOnApps,
185
+ useDashboards,
186
+ useActiveDashboard,
185
187
  useActiveBackgroundApps,
186
188
  useBackgroundApps,
187
189
  useActiveForegroundApp,
@@ -76,7 +76,7 @@ export function normalizeManifestPermissions(
76
76
  }
77
77
 
78
78
  function normalizeManifestType(raw: unknown): AppletType {
79
- return raw === "background" || raw === "system_dashboard" || raw === "standard" ? raw : "standard"
79
+ return raw === "background" || raw === "dashboard" || raw === "standard" ? raw : "standard"
80
80
  }
81
81
 
82
82
  /**
@@ -276,7 +276,7 @@ async function unpackMiniApp(
276
276
  try {
277
277
  const contents = appDir.list()
278
278
  for (const item of contents) {
279
- item.move(versionDir)
279
+ await item.move(versionDir)
280
280
  }
281
281
  } catch (error) {
282
282
  console.error("Error moving the contents of the folder to the destination directory", error)
@@ -528,7 +528,7 @@ export class BlobStore {
528
528
  return
529
529
  }
530
530
  const fileName = this.makeId()
531
- file.copy(this.fileFor(packageName, fileName))
531
+ await file.copy(this.fileFor(packageName, fileName))
532
532
  const name = basename(file.uri) || key
533
533
  const mimeType = mimeFilter || file.type || inferMime(name) || OCTET
534
534
  const meta = this.commitFile(
@@ -729,7 +729,7 @@ export class BlobStore {
729
729
  const temp = new File(tempDir, shareName)
730
730
  if (!temp.exists || temp.size !== meta.bytes) {
731
731
  if (temp.exists) temp.delete()
732
- file.copy(temp)
732
+ await file.copy(temp)
733
733
  }
734
734
  cacheReady = true
735
735
  await Share.open({
@@ -77,7 +77,19 @@ import {
77
77
  validateSettingsSet,
78
78
  type DerivedSettingsKey,
79
79
  } from "./settingsBridge"
80
- import {addOverlayApp, displayViewsFor, isOverlayApp, removeOverlayApp, setOverlayViewer} from "./pages/overlayApps"
80
+ import {mayUsePermission} from "./miniappPermissions"
81
+ import {activeDashboardAmong} from "./pages/activeDashboard"
82
+ import {
83
+ activeDashboard,
84
+ addDashboardApp,
85
+ displayViewsFor,
86
+ isDashboardApp,
87
+ isOverlayApp,
88
+ onOverlayAppsChange,
89
+ removeDashboardApp,
90
+ setActiveDashboard,
91
+ setOverlayViewer,
92
+ } from "./pages/overlayApps"
81
93
  import {pagesEligible} from "./pages/pageEligibility"
82
94
  import nativeNavigationService from "./NativeNavigationService"
83
95
  import type {DisplayPayload} from "./LocalDisplayManager"
@@ -253,9 +265,6 @@ const SYSTEM_MINIAPP_PACKAGES = new Set([
253
265
  "com.glassly.ai",
254
266
  "com.glassly.feedback",
255
267
  "com.glassly.miniappdev",
256
- // The dashboard: the settings bridge and the miniapp list/start/stop are its side menu and settings
257
- // page, and the notification app list is its notification settings' per-app filter.
258
- "com.glassly.dashboard",
259
268
  ])
260
269
  const PING_INTERVAL_MS = 5_000
261
270
  // Min gap between "transcription limit reached" notices on the glasses. A
@@ -342,6 +351,8 @@ const PERMISSION_TYPE_TO_CANONICAL: Record<string, string> = {
342
351
  MEMORY: "memory",
343
352
  AI: "ai",
344
353
  SPEAKERS: "speakers",
354
+ MANAGE_MINIAPPS: "manageMiniapps",
355
+ PLATFORM_SETTINGS: "platformSettings",
345
356
  }
346
357
 
347
358
  /**
@@ -456,6 +467,8 @@ const ALL_CANONICAL_PERMISSIONS = [
456
467
  "ai",
457
468
  "speakers",
458
469
  "displayMirror",
470
+ "manageMiniapps",
471
+ "platformSettings",
459
472
  ] as const
460
473
 
461
474
  /**
@@ -1163,11 +1176,11 @@ class LocalMiniappRuntime {
1163
1176
  // Drop this app's transcription hints; the cloud subscription rebuild
1164
1177
  // below (via replaceStreamSubscribers) re-derives the hint union without it.
1165
1178
  this.transcriptionHintsByApp.delete(packageName)
1166
- // An overlay app is going: release the slot if it holds it, and drop the
1179
+ // A dashboard is going: release the slot if it holds it, and drop the
1167
1180
  // dashboard view from its capabilities record.
1168
- if (isOverlayApp(packageName)) {
1181
+ if (isDashboardApp(packageName)) {
1169
1182
  overlayPages.remove(packageName)
1170
- removeOverlayApp(packageName)
1183
+ removeDashboardApp(packageName)
1171
1184
  }
1172
1185
  // Its gesture claims go back to whoever claimed them before.
1173
1186
  this.applyGestureClaimChanges(dropGestureClaims(this.gestureClaims, packageName))
@@ -1748,9 +1761,12 @@ class LocalMiniappRuntime {
1748
1761
  existing.lastPongAt = Date.now()
1749
1762
  existing.unansweredPingRounds = 0
1750
1763
 
1751
- // A system_dashboard app may take the overlay slot: its capabilities
1752
- // carry the dashboard view from the start.
1753
- if (existing.installedManifest?.type === "system_dashboard") addOverlayApp(packageName)
1764
+ // The dashboard that holds the role may take the overlay slot: its
1765
+ // capabilities carry the dashboard view from the start.
1766
+ if (existing.installedManifest?.type === "dashboard") {
1767
+ addDashboardApp(packageName)
1768
+ this.syncActiveDashboard()
1769
+ }
1754
1770
  const capabilities = this.capabilitiesFor(packageName)
1755
1771
 
1756
1772
  // Build the declared-permission record for the SDK's session.permissions
@@ -2133,12 +2149,11 @@ class LocalMiniappRuntime {
2133
2149
  }
2134
2150
  }
2135
2151
 
2136
- // The settings stream carries the wearer's settings: system apps only.
2137
- if (streams.includes(MiniappStreamType.SETTINGS_CHANGED) && !this.isSystemPackage(packageName)) {
2138
- this.sendResult(packageName, requestId, false, undefined, {
2139
- code: MiniappErrorCode.NOT_PERMITTED,
2140
- message: "The settings stream is restricted to system apps",
2141
- })
2152
+ // The settings stream carries the wearer's settings.
2153
+ if (
2154
+ streams.includes(MiniappStreamType.SETTINGS_CHANGED) &&
2155
+ !this.requirePermission(packageName, requestId, "PLATFORM_SETTINGS", "to subscribe to the settings stream")
2156
+ ) {
2142
2157
  return
2143
2158
  }
2144
2159
 
@@ -2152,8 +2167,9 @@ class LocalMiniappRuntime {
2152
2167
  [...cloudTranscriptionStreams].filter((stream) => app.subscriptions.has(stream)),
2153
2168
  )
2154
2169
  this.replaceStreamSubscribers(packageName, previousSubscriptions, app.subscriptions)
2155
- // An overlay app that listens to phone notifications presents them on the glasses.
2156
- if (isOverlayApp(packageName)) {
2170
+ // The overlay app presents phone notifications on the glasses while it
2171
+ // listens to them; recorded for every dashboard so the role can move.
2172
+ if (isDashboardApp(packageName)) {
2157
2173
  setOverlayViewer(packageName, app.subscriptions.has(MiniappStreamType.PHONE_NOTIFICATION))
2158
2174
  }
2159
2175
 
@@ -2385,13 +2401,13 @@ class LocalMiniappRuntime {
2385
2401
  })
2386
2402
  return
2387
2403
  }
2388
- // The dashboard view is the overlay apps' alone; anyone else's frame
2404
+ // The dashboard view is the overlay app's alone; anyone else's frame
2389
2405
  // is dropped, reported rather than silently lost.
2390
2406
  if (payload.view === "dashboard" && !isOverlayApp(packageName)) {
2391
2407
  this.sendResult(packageName, requestId, true, {
2392
2408
  status: "displayed",
2393
2409
  presented: false,
2394
- reason: "not the dashboard app",
2410
+ reason: "not the active dashboard",
2395
2411
  })
2396
2412
  return
2397
2413
  }
@@ -2415,10 +2431,11 @@ class LocalMiniappRuntime {
2415
2431
  }
2416
2432
 
2417
2433
  /**
2418
- * display.showView: the designated dashboard app takes or releases the
2419
- * overlay slot. Refused (NOT_PERMITTED) for any other app; "unsupported"
2420
- * while the glasses cannot show the dashboard view. Idempotent either way;
2421
- * the slot's state comes back to the app as a display_view change.
2434
+ * display.showView: the active dashboard takes or releases the overlay
2435
+ * slot. Refused (NOT_PERMITTED) for an app that is not a dashboard;
2436
+ * "unsupported" for a dashboard without the role and while the glasses
2437
+ * cannot show the dashboard view. Idempotent either way; the slot's state
2438
+ * comes back to the app as a display_view change.
2422
2439
  */
2423
2440
  private handleShowView(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
2424
2441
  if (!this.connectedApps.has(packageName)) {
@@ -2433,13 +2450,17 @@ class LocalMiniappRuntime {
2433
2450
  })
2434
2451
  return
2435
2452
  }
2436
- if (!isOverlayApp(packageName)) {
2453
+ if (!isDashboardApp(packageName)) {
2437
2454
  this.sendResult(packageName, requestId, false, undefined, {
2438
2455
  code: MiniappErrorCode.NOT_PERMITTED,
2439
- message: "The dashboard view belongs to system_dashboard apps",
2456
+ message: 'The dashboard view belongs to miniapps of type "dashboard"',
2440
2457
  })
2441
2458
  return
2442
2459
  }
2460
+ if (!isOverlayApp(packageName)) {
2461
+ this.sendResult(packageName, requestId, true, {status: "unsupported", reason: "not the active dashboard"})
2462
+ return
2463
+ }
2443
2464
  if (view === "main") {
2444
2465
  overlayPages.hide(packageName)
2445
2466
  this.sendResult(packageName, requestId, true, {status: "shown"})
@@ -2463,19 +2484,38 @@ class LocalMiniappRuntime {
2463
2484
  )
2464
2485
  }
2465
2486
 
2466
- // ---------------------------------------------------------------------------
2467
- // Settings bridge (session.settings) — system apps only
2468
- // ---------------------------------------------------------------------------
2469
-
2470
- private requireSystemForSettings(packageName: string, requestId: string | undefined): boolean {
2471
- if (this.isSystemPackage(packageName)) return true
2487
+ /**
2488
+ * Reject the request with PERMISSION_NOT_DECLARED unless the caller's
2489
+ * manifest declares `permission`. System apps pass without declaring it.
2490
+ */
2491
+ private requirePermission(
2492
+ packageName: string,
2493
+ requestId: string | undefined,
2494
+ permission: string,
2495
+ context: string,
2496
+ ): boolean {
2497
+ const caller = {
2498
+ system: this.isSystemPackage(packageName),
2499
+ permissions: this.connectedApps.get(packageName)?.installedManifest?.permissions,
2500
+ }
2501
+ if (mayUsePermission(caller, permission)) return true
2502
+ logPermissionNotDeclared(packageName, permission, context, `{"type": "${permission}"}`)
2472
2503
  this.sendResult(packageName, requestId, false, undefined, {
2473
- code: MiniappErrorCode.NOT_PERMITTED,
2474
- message: "The settings bridge is restricted to system apps",
2504
+ code: MiniappErrorCode.PERMISSION_NOT_DECLARED,
2505
+ message: `${permission} permission not declared in miniapp.json. Add {"type": "${permission}"} to the "permissions" array.`,
2506
+ permission,
2475
2507
  })
2476
2508
  return false
2477
2509
  }
2478
2510
 
2511
+ // ---------------------------------------------------------------------------
2512
+ // Settings bridge (session.settings) — PLATFORM_SETTINGS permission
2513
+ // ---------------------------------------------------------------------------
2514
+
2515
+ private requireSettingsPermission(packageName: string, requestId: string | undefined): boolean {
2516
+ return this.requirePermission(packageName, requestId, "PLATFORM_SETTINGS", "to use the settings bridge")
2517
+ }
2518
+
2479
2519
  /** A bridged key's current value: the store's, or the engine's for a derived key. */
2480
2520
  private settingsBridgeValue(key: string): unknown {
2481
2521
  if (isDerivedSettingsKey(key)) return this.derivedSettingValue(key)
@@ -2490,11 +2530,13 @@ class LocalMiniappRuntime {
2490
2530
  return localDisplayManager.foregroundAppOnScreen()
2491
2531
  case "ring_battery":
2492
2532
  return ringBatteryFrom(useGlassesStore.getState())
2533
+ case "active_dashboard":
2534
+ return activeDashboard()
2493
2535
  }
2494
2536
  }
2495
2537
 
2496
2538
  private handleSettingsGet(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
2497
- if (!this.requireSystemForSettings(packageName, requestId)) return
2539
+ if (!this.requireSettingsPermission(packageName, requestId)) return
2498
2540
  const values: Record<string, unknown> = {}
2499
2541
  for (const key of selectSettingsBridgeKeys(payload.keys)) values[key] = this.settingsBridgeValue(key)
2500
2542
  this.sendResult(packageName, requestId, true, {values})
@@ -2505,7 +2547,7 @@ class LocalMiniappRuntime {
2505
2547
  payload: Record<string, unknown>,
2506
2548
  requestId?: string,
2507
2549
  ): Promise<void> {
2508
- if (!this.requireSystemForSettings(packageName, requestId)) return
2550
+ if (!this.requireSettingsPermission(packageName, requestId)) return
2509
2551
  const validated = validateSettingsSet(payload)
2510
2552
  if (!validated.ok) {
2511
2553
  this.sendResult(packageName, requestId, false, undefined, {code: validated.code, message: validated.message})
@@ -2525,7 +2567,11 @@ class LocalMiniappRuntime {
2525
2567
  }
2526
2568
 
2527
2569
  private async handleNotificationAppsList(packageName: string, requestId?: string): Promise<void> {
2528
- if (!this.requireSystemForSettings(packageName, requestId)) return
2570
+ if (
2571
+ !this.requirePermission(packageName, requestId, "READ_NOTIFICATIONS", "to list the apps that post notifications")
2572
+ ) {
2573
+ return
2574
+ }
2529
2575
  try {
2530
2576
  this.sendResult(packageName, requestId, true, await installedNotificationApps())
2531
2577
  } catch (err) {
@@ -2539,8 +2585,9 @@ class LocalMiniappRuntime {
2539
2585
  /**
2540
2586
  * Push bridged settings as they change: stored keys from the store, the
2541
2587
  * derived keys whenever their inputs move (phone activity, the display,
2542
- * the apps store, the ring). Subscribers are system apps only (gated at
2543
- * SUBSCRIBE), so this is a no-op fan-out for everyone else.
2588
+ * the apps store, the ring, the dashboard role). Subscribers declared
2589
+ * PLATFORM_SETTINGS (gated at SUBSCRIBE), so this is a no-op fan-out for
2590
+ * everyone else.
2544
2591
  */
2545
2592
  private ensureSettingsBridgeWired(): void {
2546
2593
  if (this.settingsBridgeWired) return
@@ -2576,6 +2623,7 @@ class LocalMiniappRuntime {
2576
2623
  onAppForegroundChange((foregrounded) => this.broadcastVisibility(foregrounded))
2577
2624
  localDisplayManager.onActiveDisplayChange(pushDerived)
2578
2625
  useAppStatusStore.subscribe(pushDerived)
2626
+ onOverlayAppsChange(pushDerived)
2579
2627
  useGlassesStore.subscribe(
2580
2628
  (s) => `${s.ringConnected}:${s.ringBatteryLevel}:${s.controllerConnected}:${s.controllerBatteryLevel}`,
2581
2629
  pushDerived,
@@ -5196,6 +5244,31 @@ class LocalMiniappRuntime {
5196
5244
  useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.cfw_native_navigation.key), broadcast)
5197
5245
  // The dashboard master switch flips the dashboard view.
5198
5246
  useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.custom_dashboard_enabled.key), broadcast)
5247
+ // The wearer's choice and the installed dashboards decide who holds the role.
5248
+ const syncRole = () => this.syncActiveDashboard()
5249
+ useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.active_dashboard.key), syncRole)
5250
+ useAppStatusStore.subscribe(syncRole)
5251
+ syncRole()
5252
+ }
5253
+
5254
+ /**
5255
+ * Recompute who holds the dashboard role (ADR-0009). The previous holder
5256
+ * gives up the slot first, so it hears the view change while it is still
5257
+ * the overlay app; then every app sees its views again.
5258
+ */
5259
+ private syncActiveDashboard(): void {
5260
+ const candidates: Array<{packageName: string; type?: string}> = [...useAppStatusStore.getState().apps]
5261
+ // A dashboard can connect at launch before the apps store has listed it.
5262
+ for (const [packageName, app] of this.connectedApps) {
5263
+ if (candidates.some((candidate) => candidate.packageName === packageName)) continue
5264
+ candidates.push({packageName, type: app.installedManifest?.type})
5265
+ }
5266
+ const next = activeDashboardAmong(candidates)
5267
+ const previous = activeDashboard()
5268
+ if (next === previous) return
5269
+ if (previous !== null) overlayPages.hide(previous, "replaced")
5270
+ setActiveDashboard(next)
5271
+ this.broadcastCapabilities()
5199
5272
  }
5200
5273
 
5201
5274
  /** Capabilities as `packageName` sees them: the model profile plus the views this app may draw. */
@@ -5668,18 +5741,20 @@ class LocalMiniappRuntime {
5668
5741
  }
5669
5742
  }
5670
5743
 
5671
- /** Reject the request with NOT_PERMITTED unless the caller is a system app. */
5672
- private requireSystemCaller(
5744
+ /** Reject the request unless the caller may list, start and stop other miniapps (MANAGE_MINIAPPS). */
5745
+ private requireMiniappsPermission(
5673
5746
  packageName: string,
5674
5747
  requestId: string | undefined,
5675
5748
  op: InteropAuditEvent["op"],
5676
5749
  target?: string,
5677
5750
  ): boolean {
5678
- if (this.isSystemPackage(packageName)) return true
5679
- this.auditInterop({caller: packageName, op, target, ok: false, errorCode: MiniappErrorCode.NOT_PERMITTED})
5680
- this.sendResult(packageName, requestId, false, undefined, {
5681
- code: MiniappErrorCode.NOT_PERMITTED,
5682
- message: "Inter-miniapp APIs are restricted to system apps",
5751
+ if (this.requirePermission(packageName, requestId, "MANAGE_MINIAPPS", `to ${op} miniapps`)) return true
5752
+ this.auditInterop({
5753
+ caller: packageName,
5754
+ op,
5755
+ target,
5756
+ ok: false,
5757
+ errorCode: MiniappErrorCode.PERMISSION_NOT_DECLARED,
5683
5758
  })
5684
5759
  return false
5685
5760
  }
@@ -5704,7 +5779,7 @@ class LocalMiniappRuntime {
5704
5779
  }
5705
5780
 
5706
5781
  private handleMiniappsList(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
5707
- if (!this.requireSystemCaller(packageName, requestId, "list")) return
5782
+ if (!this.requireMiniappsPermission(packageName, requestId, "list")) return
5708
5783
  const includeIncompatible = payload.includeIncompatible === true
5709
5784
  const infos = this.interopApps()
5710
5785
  .filter(
@@ -5725,7 +5800,7 @@ class LocalMiniappRuntime {
5725
5800
  requestId?: string,
5726
5801
  ): Promise<void> {
5727
5802
  const target = payload.packageName as string | undefined
5728
- if (!this.requireSystemCaller(packageName, requestId, "start", target)) return
5803
+ if (!this.requireMiniappsPermission(packageName, requestId, "start", target)) return
5729
5804
  const app = target ? this.interopApps().find((a) => a.packageName === target) : undefined
5730
5805
  if (!target || !app) {
5731
5806
  this.sendResult(packageName, requestId, false, undefined, {
@@ -5817,7 +5892,7 @@ class LocalMiniappRuntime {
5817
5892
  requestId?: string,
5818
5893
  ): Promise<void> {
5819
5894
  const target = payload.packageName as string | undefined
5820
- if (!this.requireSystemCaller(packageName, requestId, "stop", target)) return
5895
+ if (!this.requireMiniappsPermission(packageName, requestId, "stop", target)) return
5821
5896
  if (!target) {
5822
5897
  this.sendResult(packageName, requestId, false, undefined, {
5823
5898
  code: MiniappErrorCode.APP_NOT_FOUND,
@@ -31,6 +31,7 @@ import {storage} from "../utils/storage/storage"
31
31
  import appRegistry, {getLocalAppRunningState, saveLocalAppRunningState} from "./AppRegistry"
32
32
  import devServerBridge from "./DevServerBridge"
33
33
  import localMiniappRuntime, {type InstalledMiniappManifest} from "./LocalMiniappRuntime"
34
+ import {activeDashboardAmong} from "./pages/activeDashboard"
34
35
  import type {MiniappLaunchSource} from "@glassly/miniapp"
35
36
  import type {JSRouter} from "./JSRouter"
36
37
 
@@ -306,17 +307,21 @@ class MiniappLauncher {
306
307
  *
307
308
  * Always On apps (the dashboard) start whether or not they were running
308
309
  * when the app was last killed, and get a switcher card like any other app.
310
+ * A dashboard that does not hold the role (ADR-0009) is not started by its
311
+ * Always On flag.
309
312
  * Both restore paths skip apps missing required OS permissions without
310
313
  * prompting or clearing their saved running/Always On choice.
311
314
  */
312
315
  async autostartLocalMiniapps(): Promise<void> {
313
316
  const apps = await appRegistry.getInstalledMiniapps()
317
+ const activeDashboard = activeDashboardAmong(apps)
314
318
  for (const app of apps) {
315
319
  // Only phone-hosted miniapps have a JSContext to re-spawn. Offline
316
320
  // built-ins (`local:false, offline:true`) restore their native running
317
321
  // state elsewhere and have no bundle to launch.
318
322
  if (!app.local) continue
319
- if (!app.alwaysOn && !getLocalAppRunningState(app.packageName)) continue
323
+ const alwaysOn = app.alwaysOn && (app.type !== "dashboard" || app.packageName === activeDashboard)
324
+ if (!alwaysOn && !getLocalAppRunningState(app.packageName)) continue
320
325
  if (this.isRunning(app.packageName)) continue
321
326
  try {
322
327
  const missing = await permissions.missingRequired(app.permissions ?? [])
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Whether a miniapp may use an API gated on a manifest permission
3
+ * (miniapp.json -> permissions): `session.miniapps` needs MANAGE_MINIAPPS,
4
+ * `session.settings` PLATFORM_SETTINGS, the notification app list
5
+ * READ_NOTIFICATIONS. System apps pass without declaring it. Pure, so the
6
+ * runtime only does the reply.
7
+ */
8
+ export interface PermissionCaller {
9
+ system: boolean
10
+ permissions?: ReadonlyArray<{type?: string}>
11
+ }
12
+
13
+ export function declaresPermission(permissions: PermissionCaller["permissions"], permission: string): boolean {
14
+ return (permissions ?? []).some((declared) => declared.type?.toUpperCase() === permission)
15
+ }
16
+
17
+ export function mayUsePermission(caller: PermissionCaller, permission: string): boolean {
18
+ return caller.system || declaresPermission(caller.permissions, permission)
19
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
- * The overlay apps' pages (ADR-0006): one Page per app that has taken the
3
- * slot, the only Page implementation the page host shows. The engine keeps
4
- * the slot and the eligibility watch; a page only relays: holding the slot
5
- * is reported to its app as a view change, and the app's frames reach the
6
- * slot through `renderOverlay` under its package name. Whichever app asked
7
- * last holds the slot; the previous one hears it was replaced. The runtime
8
- * supplies the sink that delivers to apps, so this module never imports it.
2
+ * The overlay app's page (ADR-0006, ADR-0009): one Page per dashboard that
3
+ * has taken the slot, the only Page implementation the page host shows. The
4
+ * engine keeps the slot and the eligibility watch; a page only relays:
5
+ * holding the slot is reported to its app as a view change, and the app's
6
+ * frames reach the slot through `renderOverlay` under its package name. Only
7
+ * the dashboard that holds the role may show; when the role moves, the
8
+ * previous holder hears it was replaced. The runtime supplies the sink that
9
+ * delivers to apps, so this module never imports it.
9
10
  */
10
11
  import pageHost, {type Page, type PageHiddenReason} from "./PageHost"
11
12
  import {isOverlayApp} from "./overlayApps"
@@ -44,10 +45,10 @@ class OverlayPages {
44
45
  return pageHost.showing === page
45
46
  }
46
47
 
47
- /** Release the slot if the app holds it. */
48
- hide(packageName: string): void {
48
+ /** Release the slot if the app holds it; "replaced" when the dashboard role moved on. */
49
+ hide(packageName: string, reason: PageHiddenReason = "hidden"): void {
49
50
  const page = this.pages.get(packageName)
50
- if (page) pageHost.hide(page)
51
+ if (page) pageHost.hide(page, reason)
51
52
  }
52
53
 
53
54
  /** The app stopped: release the slot without telling it, and forget its page. */
@@ -72,10 +72,10 @@ class PageHost {
72
72
  }
73
73
 
74
74
  /** Release the slot if `page` holds it. */
75
- hide(page: Page): void {
75
+ hide(page: Page, reason: PageHiddenReason = "hidden"): void {
76
76
  this.enqueue(() => {
77
77
  if (this.showingPage !== page) return
78
- this.release("hidden")
78
+ this.release(reason)
79
79
  })
80
80
  }
81
81
 
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Who holds the dashboard role (ADR-0009): the wearer's choice when that
3
+ * miniapp is installed, else the default (the bundled dashboard), else the
4
+ * first installed dashboard by package name, else nobody. Read at decision
5
+ * time, never cached, so an uninstall hands the role back without anyone
6
+ * rewriting the setting.
7
+ */
8
+ import {SETTINGS, useSettingsStore} from "../../stores/settings"
9
+
10
+ export function resolveActiveDashboard(installed: readonly string[], chosen: unknown, fallback: string): string | null {
11
+ if (typeof chosen === "string" && installed.includes(chosen)) return chosen
12
+ if (installed.includes(fallback)) return fallback
13
+ return [...installed].sort()[0] ?? null
14
+ }
15
+
16
+ /** The active dashboard among `apps`; only miniapps whose manifest type is "dashboard" can hold the role. */
17
+ export function activeDashboardAmong(apps: ReadonlyArray<{packageName: string; type?: string}>): string | null {
18
+ const installed = apps.filter((app) => app.type === "dashboard").map((app) => app.packageName)
19
+ return resolveActiveDashboard(
20
+ installed,
21
+ useSettingsStore.getState().getSetting(SETTINGS.active_dashboard.key),
22
+ SETTINGS.active_dashboard.defaultValue(),
23
+ )
24
+ }