@glassly/engine 0.3.0-dev.462 → 0.3.0-dev.469

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 (72) 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 +3 -3
  8. package/build/generated/releaseMetadata.js.map +1 -1
  9. package/build/index.d.ts +1 -1
  10. package/build/index.d.ts.map +1 -1
  11. package/build/index.js +1 -1
  12. package/build/index.js.map +1 -1
  13. package/build/services/AppRegistry.js +1 -1
  14. package/build/services/AppRegistry.js.map +1 -1
  15. package/build/services/LocalMiniappRuntime.d.ts +22 -9
  16. package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
  17. package/build/services/LocalMiniappRuntime.js +108 -50
  18. package/build/services/LocalMiniappRuntime.js.map +1 -1
  19. package/build/services/MiniappLauncher.d.ts +2 -0
  20. package/build/services/MiniappLauncher.d.ts.map +1 -1
  21. package/build/services/MiniappLauncher.js +6 -1
  22. package/build/services/MiniappLauncher.js.map +1 -1
  23. package/build/services/miniappPermissions.d.ts +16 -0
  24. package/build/services/miniappPermissions.d.ts.map +1 -0
  25. package/build/services/miniappPermissions.js +7 -0
  26. package/build/services/miniappPermissions.js.map +1 -0
  27. package/build/services/pages/OverlayPages.d.ts +13 -2
  28. package/build/services/pages/OverlayPages.d.ts.map +1 -1
  29. package/build/services/pages/OverlayPages.js +11 -10
  30. package/build/services/pages/OverlayPages.js.map +1 -1
  31. package/build/services/pages/PageHost.d.ts +1 -1
  32. package/build/services/pages/PageHost.d.ts.map +1 -1
  33. package/build/services/pages/PageHost.js +2 -2
  34. package/build/services/pages/PageHost.js.map +1 -1
  35. package/build/services/pages/activeDashboard.d.ts +7 -0
  36. package/build/services/pages/activeDashboard.d.ts.map +1 -0
  37. package/build/services/pages/activeDashboard.js +21 -0
  38. package/build/services/pages/activeDashboard.js.map +1 -0
  39. package/build/services/pages/overlayApps.d.ts +20 -13
  40. package/build/services/pages/overlayApps.d.ts.map +1 -1
  41. package/build/services/pages/overlayApps.js +50 -31
  42. package/build/services/pages/overlayApps.js.map +1 -1
  43. package/build/services/settingsBridge.d.ts +15 -14
  44. package/build/services/settingsBridge.d.ts.map +1 -1
  45. package/build/services/settingsBridge.js +20 -20
  46. package/build/services/settingsBridge.js.map +1 -1
  47. package/build/stores/apps.d.ts +5 -0
  48. package/build/stores/apps.d.ts.map +1 -1
  49. package/build/stores/apps.js +40 -2
  50. package/build/stores/apps.js.map +1 -1
  51. package/build/stores/settings.d.ts.map +1 -1
  52. package/build/stores/settings.js +19 -45
  53. package/build/stores/settings.js.map +1 -1
  54. package/build/types/applet.d.ts +2 -2
  55. package/build/types/applet.d.ts.map +1 -1
  56. package/build/types/applet.js.map +1 -1
  57. package/package.json +7 -7
  58. package/src/facades/miniapps.ts +9 -0
  59. package/src/generated/releaseMetadata.ts +3 -3
  60. package/src/index.ts +2 -0
  61. package/src/services/AppRegistry.ts +1 -1
  62. package/src/services/LocalMiniappRuntime.ts +129 -50
  63. package/src/services/MiniappLauncher.ts +6 -1
  64. package/src/services/miniappPermissions.ts +19 -0
  65. package/src/services/pages/OverlayPages.ts +11 -10
  66. package/src/services/pages/PageHost.ts +2 -2
  67. package/src/services/pages/activeDashboard.ts +24 -0
  68. package/src/services/pages/overlayApps.ts +51 -30
  69. package/src/services/settingsBridge.ts +20 -20
  70. package/src/stores/apps.ts +44 -2
  71. package/src/stores/settings.ts +19 -45
  72. 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.3.0-dev.462",
3
+ "version": "0.3.0-dev.469",
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.3.0-dev.462",
101
- "@glassly/cloud-client": "0.3.0-dev.462",
102
- "@glassly/cloud-protocol": "0.3.0-dev.462",
103
- "@glassly/crust": "0.3.0-dev.462",
104
- "@glassly/miniapp": "0.3.0-dev.462",
100
+ "@glassly/bluetooth-sdk": "0.3.0-dev.469",
101
+ "@glassly/cloud-client": "0.3.0-dev.469",
102
+ "@glassly/cloud-protocol": "0.3.0-dev.469",
103
+ "@glassly/crust": "0.3.0-dev.469",
104
+ "@glassly/miniapp": "0.3.0-dev.469",
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.3.0-dev.462"
110
+ "@glassly/llm-providers": "0.3.0-dev.469"
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
  }
@@ -12,9 +12,9 @@ export interface EngineReleaseMetadata {
12
12
  export const ENGINE_RELEASE_METADATA: Readonly<EngineReleaseMetadata> = Object.freeze({
13
13
  "schemaVersion": 1,
14
14
  "familyBaseVersion": "0.3.0",
15
- "releaseIdentity": "0.3.0-dev.462",
16
- "releaseSetId": "glassly-0.3.0-dev.462",
17
- "sourceCommit": "6d9eaccddb17582a14925742c5da672feeca3cee",
15
+ "releaseIdentity": "0.3.0-dev.469",
16
+ "releaseSetId": "glassly-0.3.0-dev.469",
17
+ "sourceCommit": "5abdf2c55c0f8f25cf402afca85f7677c753443f",
18
18
  "otaManifestUrl": null,
19
19
  "otaManifestSha256": null
20
20
  })
package/src/index.ts CHANGED
@@ -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
  /**
@@ -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))
@@ -1551,7 +1564,11 @@ class LocalMiniappRuntime {
1551
1564
  this.handleOpenUrl(packageName, payload)
1552
1565
  break
1553
1566
  case MiniappRequestType.DISPLAY_DIAGNOSTICS: {
1554
- const enabled = useSettingsStore.getState().getSetting(SETTINGS.debug_console.key) === true
1567
+ // The records are parsed out of the G2PERF lines, which the SDK only logs while g2_perf_logs is on.
1568
+ const settings = useSettingsStore.getState()
1569
+ const enabled =
1570
+ settings.getSetting(SETTINGS.debug_console.key) === true &&
1571
+ settings.getSetting(SETTINGS.g2_perf_logs.key) === true
1555
1572
  this.sendResult(packageName, requestId, true, {
1556
1573
  enabled,
1557
1574
  generatedAt: Date.now(),
@@ -1748,9 +1765,12 @@ class LocalMiniappRuntime {
1748
1765
  existing.lastPongAt = Date.now()
1749
1766
  existing.unansweredPingRounds = 0
1750
1767
 
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)
1768
+ // The dashboard that holds the role may take the overlay slot: its
1769
+ // capabilities carry the dashboard view from the start.
1770
+ if (existing.installedManifest?.type === "dashboard") {
1771
+ addDashboardApp(packageName)
1772
+ this.syncActiveDashboard()
1773
+ }
1754
1774
  const capabilities = this.capabilitiesFor(packageName)
1755
1775
 
1756
1776
  // Build the declared-permission record for the SDK's session.permissions
@@ -2133,12 +2153,11 @@ class LocalMiniappRuntime {
2133
2153
  }
2134
2154
  }
2135
2155
 
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
- })
2156
+ // The settings stream carries the wearer's settings.
2157
+ if (
2158
+ streams.includes(MiniappStreamType.SETTINGS_CHANGED) &&
2159
+ !this.requirePermission(packageName, requestId, "PLATFORM_SETTINGS", "to subscribe to the settings stream")
2160
+ ) {
2142
2161
  return
2143
2162
  }
2144
2163
 
@@ -2152,8 +2171,9 @@ class LocalMiniappRuntime {
2152
2171
  [...cloudTranscriptionStreams].filter((stream) => app.subscriptions.has(stream)),
2153
2172
  )
2154
2173
  this.replaceStreamSubscribers(packageName, previousSubscriptions, app.subscriptions)
2155
- // An overlay app that listens to phone notifications presents them on the glasses.
2156
- if (isOverlayApp(packageName)) {
2174
+ // The overlay app presents phone notifications on the glasses while it
2175
+ // listens to them; recorded for every dashboard so the role can move.
2176
+ if (isDashboardApp(packageName)) {
2157
2177
  setOverlayViewer(packageName, app.subscriptions.has(MiniappStreamType.PHONE_NOTIFICATION))
2158
2178
  }
2159
2179
 
@@ -2385,13 +2405,13 @@ class LocalMiniappRuntime {
2385
2405
  })
2386
2406
  return
2387
2407
  }
2388
- // The dashboard view is the overlay apps' alone; anyone else's frame
2408
+ // The dashboard view is the overlay app's alone; anyone else's frame
2389
2409
  // is dropped, reported rather than silently lost.
2390
2410
  if (payload.view === "dashboard" && !isOverlayApp(packageName)) {
2391
2411
  this.sendResult(packageName, requestId, true, {
2392
2412
  status: "displayed",
2393
2413
  presented: false,
2394
- reason: "not the dashboard app",
2414
+ reason: "not the active dashboard",
2395
2415
  })
2396
2416
  return
2397
2417
  }
@@ -2415,10 +2435,11 @@ class LocalMiniappRuntime {
2415
2435
  }
2416
2436
 
2417
2437
  /**
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.
2438
+ * display.showView: the active dashboard takes or releases the overlay
2439
+ * slot. Refused (NOT_PERMITTED) for an app that is not a dashboard;
2440
+ * "unsupported" for a dashboard without the role and while the glasses
2441
+ * cannot show the dashboard view. Idempotent either way; the slot's state
2442
+ * comes back to the app as a display_view change.
2422
2443
  */
2423
2444
  private handleShowView(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
2424
2445
  if (!this.connectedApps.has(packageName)) {
@@ -2433,13 +2454,17 @@ class LocalMiniappRuntime {
2433
2454
  })
2434
2455
  return
2435
2456
  }
2436
- if (!isOverlayApp(packageName)) {
2457
+ if (!isDashboardApp(packageName)) {
2437
2458
  this.sendResult(packageName, requestId, false, undefined, {
2438
2459
  code: MiniappErrorCode.NOT_PERMITTED,
2439
- message: "The dashboard view belongs to system_dashboard apps",
2460
+ message: 'The dashboard view belongs to miniapps of type "dashboard"',
2440
2461
  })
2441
2462
  return
2442
2463
  }
2464
+ if (!isOverlayApp(packageName)) {
2465
+ this.sendResult(packageName, requestId, true, {status: "unsupported", reason: "not the active dashboard"})
2466
+ return
2467
+ }
2443
2468
  if (view === "main") {
2444
2469
  overlayPages.hide(packageName)
2445
2470
  this.sendResult(packageName, requestId, true, {status: "shown"})
@@ -2463,19 +2488,38 @@ class LocalMiniappRuntime {
2463
2488
  )
2464
2489
  }
2465
2490
 
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
2491
+ /**
2492
+ * Reject the request with PERMISSION_NOT_DECLARED unless the caller's
2493
+ * manifest declares `permission`. System apps pass without declaring it.
2494
+ */
2495
+ private requirePermission(
2496
+ packageName: string,
2497
+ requestId: string | undefined,
2498
+ permission: string,
2499
+ context: string,
2500
+ ): boolean {
2501
+ const caller = {
2502
+ system: this.isSystemPackage(packageName),
2503
+ permissions: this.connectedApps.get(packageName)?.installedManifest?.permissions,
2504
+ }
2505
+ if (mayUsePermission(caller, permission)) return true
2506
+ logPermissionNotDeclared(packageName, permission, context, `{"type": "${permission}"}`)
2472
2507
  this.sendResult(packageName, requestId, false, undefined, {
2473
- code: MiniappErrorCode.NOT_PERMITTED,
2474
- message: "The settings bridge is restricted to system apps",
2508
+ code: MiniappErrorCode.PERMISSION_NOT_DECLARED,
2509
+ message: `${permission} permission not declared in miniapp.json. Add {"type": "${permission}"} to the "permissions" array.`,
2510
+ permission,
2475
2511
  })
2476
2512
  return false
2477
2513
  }
2478
2514
 
2515
+ // ---------------------------------------------------------------------------
2516
+ // Settings bridge (session.settings) — PLATFORM_SETTINGS permission
2517
+ // ---------------------------------------------------------------------------
2518
+
2519
+ private requireSettingsPermission(packageName: string, requestId: string | undefined): boolean {
2520
+ return this.requirePermission(packageName, requestId, "PLATFORM_SETTINGS", "to use the settings bridge")
2521
+ }
2522
+
2479
2523
  /** A bridged key's current value: the store's, or the engine's for a derived key. */
2480
2524
  private settingsBridgeValue(key: string): unknown {
2481
2525
  if (isDerivedSettingsKey(key)) return this.derivedSettingValue(key)
@@ -2490,11 +2534,13 @@ class LocalMiniappRuntime {
2490
2534
  return localDisplayManager.foregroundAppOnScreen()
2491
2535
  case "ring_battery":
2492
2536
  return ringBatteryFrom(useGlassesStore.getState())
2537
+ case "active_dashboard":
2538
+ return activeDashboard()
2493
2539
  }
2494
2540
  }
2495
2541
 
2496
2542
  private handleSettingsGet(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
2497
- if (!this.requireSystemForSettings(packageName, requestId)) return
2543
+ if (!this.requireSettingsPermission(packageName, requestId)) return
2498
2544
  const values: Record<string, unknown> = {}
2499
2545
  for (const key of selectSettingsBridgeKeys(payload.keys)) values[key] = this.settingsBridgeValue(key)
2500
2546
  this.sendResult(packageName, requestId, true, {values})
@@ -2505,7 +2551,7 @@ class LocalMiniappRuntime {
2505
2551
  payload: Record<string, unknown>,
2506
2552
  requestId?: string,
2507
2553
  ): Promise<void> {
2508
- if (!this.requireSystemForSettings(packageName, requestId)) return
2554
+ if (!this.requireSettingsPermission(packageName, requestId)) return
2509
2555
  const validated = validateSettingsSet(payload)
2510
2556
  if (!validated.ok) {
2511
2557
  this.sendResult(packageName, requestId, false, undefined, {code: validated.code, message: validated.message})
@@ -2525,7 +2571,11 @@ class LocalMiniappRuntime {
2525
2571
  }
2526
2572
 
2527
2573
  private async handleNotificationAppsList(packageName: string, requestId?: string): Promise<void> {
2528
- if (!this.requireSystemForSettings(packageName, requestId)) return
2574
+ if (
2575
+ !this.requirePermission(packageName, requestId, "READ_NOTIFICATIONS", "to list the apps that post notifications")
2576
+ ) {
2577
+ return
2578
+ }
2529
2579
  try {
2530
2580
  this.sendResult(packageName, requestId, true, await installedNotificationApps())
2531
2581
  } catch (err) {
@@ -2539,8 +2589,9 @@ class LocalMiniappRuntime {
2539
2589
  /**
2540
2590
  * Push bridged settings as they change: stored keys from the store, the
2541
2591
  * 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.
2592
+ * the apps store, the ring, the dashboard role). Subscribers declared
2593
+ * PLATFORM_SETTINGS (gated at SUBSCRIBE), so this is a no-op fan-out for
2594
+ * everyone else.
2544
2595
  */
2545
2596
  private ensureSettingsBridgeWired(): void {
2546
2597
  if (this.settingsBridgeWired) return
@@ -2576,6 +2627,7 @@ class LocalMiniappRuntime {
2576
2627
  onAppForegroundChange((foregrounded) => this.broadcastVisibility(foregrounded))
2577
2628
  localDisplayManager.onActiveDisplayChange(pushDerived)
2578
2629
  useAppStatusStore.subscribe(pushDerived)
2630
+ onOverlayAppsChange(pushDerived)
2579
2631
  useGlassesStore.subscribe(
2580
2632
  (s) => `${s.ringConnected}:${s.ringBatteryLevel}:${s.controllerConnected}:${s.controllerBatteryLevel}`,
2581
2633
  pushDerived,
@@ -5196,6 +5248,31 @@ class LocalMiniappRuntime {
5196
5248
  useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.cfw_native_navigation.key), broadcast)
5197
5249
  // The dashboard master switch flips the dashboard view.
5198
5250
  useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.custom_dashboard_enabled.key), broadcast)
5251
+ // The wearer's choice and the installed dashboards decide who holds the role.
5252
+ const syncRole = () => this.syncActiveDashboard()
5253
+ useSettingsStore.subscribe((s) => s.getSetting(SETTINGS.active_dashboard.key), syncRole)
5254
+ useAppStatusStore.subscribe(syncRole)
5255
+ syncRole()
5256
+ }
5257
+
5258
+ /**
5259
+ * Recompute who holds the dashboard role (ADR-0009). The previous holder
5260
+ * gives up the slot first, so it hears the view change while it is still
5261
+ * the overlay app; then every app sees its views again.
5262
+ */
5263
+ private syncActiveDashboard(): void {
5264
+ const candidates: Array<{packageName: string; type?: string}> = [...useAppStatusStore.getState().apps]
5265
+ // A dashboard can connect at launch before the apps store has listed it.
5266
+ for (const [packageName, app] of this.connectedApps) {
5267
+ if (candidates.some((candidate) => candidate.packageName === packageName)) continue
5268
+ candidates.push({packageName, type: app.installedManifest?.type})
5269
+ }
5270
+ const next = activeDashboardAmong(candidates)
5271
+ const previous = activeDashboard()
5272
+ if (next === previous) return
5273
+ if (previous !== null) overlayPages.hide(previous, "replaced")
5274
+ setActiveDashboard(next)
5275
+ this.broadcastCapabilities()
5199
5276
  }
5200
5277
 
5201
5278
  /** Capabilities as `packageName` sees them: the model profile plus the views this app may draw. */
@@ -5668,18 +5745,20 @@ class LocalMiniappRuntime {
5668
5745
  }
5669
5746
  }
5670
5747
 
5671
- /** Reject the request with NOT_PERMITTED unless the caller is a system app. */
5672
- private requireSystemCaller(
5748
+ /** Reject the request unless the caller may list, start and stop other miniapps (MANAGE_MINIAPPS). */
5749
+ private requireMiniappsPermission(
5673
5750
  packageName: string,
5674
5751
  requestId: string | undefined,
5675
5752
  op: InteropAuditEvent["op"],
5676
5753
  target?: string,
5677
5754
  ): 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",
5755
+ if (this.requirePermission(packageName, requestId, "MANAGE_MINIAPPS", `to ${op} miniapps`)) return true
5756
+ this.auditInterop({
5757
+ caller: packageName,
5758
+ op,
5759
+ target,
5760
+ ok: false,
5761
+ errorCode: MiniappErrorCode.PERMISSION_NOT_DECLARED,
5683
5762
  })
5684
5763
  return false
5685
5764
  }
@@ -5704,7 +5783,7 @@ class LocalMiniappRuntime {
5704
5783
  }
5705
5784
 
5706
5785
  private handleMiniappsList(packageName: string, payload: Record<string, unknown>, requestId?: string): void {
5707
- if (!this.requireSystemCaller(packageName, requestId, "list")) return
5786
+ if (!this.requireMiniappsPermission(packageName, requestId, "list")) return
5708
5787
  const includeIncompatible = payload.includeIncompatible === true
5709
5788
  const infos = this.interopApps()
5710
5789
  .filter(
@@ -5725,7 +5804,7 @@ class LocalMiniappRuntime {
5725
5804
  requestId?: string,
5726
5805
  ): Promise<void> {
5727
5806
  const target = payload.packageName as string | undefined
5728
- if (!this.requireSystemCaller(packageName, requestId, "start", target)) return
5807
+ if (!this.requireMiniappsPermission(packageName, requestId, "start", target)) return
5729
5808
  const app = target ? this.interopApps().find((a) => a.packageName === target) : undefined
5730
5809
  if (!target || !app) {
5731
5810
  this.sendResult(packageName, requestId, false, undefined, {
@@ -5817,7 +5896,7 @@ class LocalMiniappRuntime {
5817
5896
  requestId?: string,
5818
5897
  ): Promise<void> {
5819
5898
  const target = payload.packageName as string | undefined
5820
- if (!this.requireSystemCaller(packageName, requestId, "stop", target)) return
5899
+ if (!this.requireMiniappsPermission(packageName, requestId, "stop", target)) return
5821
5900
  if (!target) {
5822
5901
  this.sendResult(packageName, requestId, false, undefined, {
5823
5902
  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
+ }