homebridge-plugin-utils 2.0.0 → 2.2.0

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 (99) hide show
  1. package/build/tsconfig.json +15 -0
  2. package/dist/backpressure.d.ts +1 -1
  3. package/dist/backpressure.js +4 -4
  4. package/dist/backpressure.js.map +1 -1
  5. package/dist/cli/index.d.ts +93 -5
  6. package/dist/cli/index.js +296 -30
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/clock-double.js +2 -2
  9. package/dist/clock-double.js.map +1 -1
  10. package/dist/disposable-stack.d.ts +59 -0
  11. package/dist/disposable-stack.js +155 -0
  12. package/dist/disposable-stack.js.map +1 -0
  13. package/dist/docChrome.d.ts +263 -0
  14. package/dist/docChrome.js +364 -0
  15. package/dist/docChrome.js.map +1 -0
  16. package/dist/featureOptions-docs.d.ts +2 -2
  17. package/dist/featureOptions-docs.js +1 -1
  18. package/dist/featureOptions.d.ts +5 -5
  19. package/dist/featureOptions.js +6 -6
  20. package/dist/ffmpeg/exec.d.ts +2 -2
  21. package/dist/ffmpeg/exec.js +2 -2
  22. package/dist/ffmpeg/fmp4-builders.d.ts +77 -0
  23. package/dist/ffmpeg/fmp4-builders.js +163 -0
  24. package/dist/ffmpeg/fmp4-builders.js.map +1 -0
  25. package/dist/ffmpeg/index.d.ts +1 -0
  26. package/dist/ffmpeg/index.js +1 -0
  27. package/dist/ffmpeg/index.js.map +1 -1
  28. package/dist/ffmpeg/mp4-assembler.d.ts +45 -4
  29. package/dist/ffmpeg/mp4-assembler.js +45 -8
  30. package/dist/ffmpeg/mp4-assembler.js.map +1 -1
  31. package/dist/ffmpeg/mp4-parser.d.ts +1 -1
  32. package/dist/ffmpeg/mp4-parser.js +1 -1
  33. package/dist/ffmpeg/options.d.ts +17 -0
  34. package/dist/ffmpeg/options.js +50 -25
  35. package/dist/ffmpeg/options.js.map +1 -1
  36. package/dist/ffmpeg/process.d.ts +1 -1
  37. package/dist/ffmpeg/process.js +4 -4
  38. package/dist/ffmpeg/process.js.map +1 -1
  39. package/dist/ffmpeg/record.d.ts +26 -1
  40. package/dist/ffmpeg/record.js +12 -0
  41. package/dist/ffmpeg/record.js.map +1 -1
  42. package/dist/ffmpeg/recording-process-double.d.ts +13 -0
  43. package/dist/ffmpeg/recording-process-double.js +22 -0
  44. package/dist/ffmpeg/recording-process-double.js.map +1 -1
  45. package/dist/ffmpeg/rtp-parser.d.ts +1 -1
  46. package/dist/ffmpeg/rtp-parser.js +1 -1
  47. package/dist/ffmpeg/rtp.d.ts +4 -4
  48. package/dist/ffmpeg/rtp.js +7 -6
  49. package/dist/ffmpeg/rtp.js.map +1 -1
  50. package/dist/ffmpeg/stream.js +1 -1
  51. package/dist/ffmpeg/stream.js.map +1 -1
  52. package/dist/index.d.ts +3 -0
  53. package/dist/index.js +3 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/logclient/auth.d.ts +3 -3
  56. package/dist/logclient/auth.js +2 -2
  57. package/dist/logclient/auth.js.map +1 -1
  58. package/dist/logclient/cli-run.js +2 -2
  59. package/dist/logclient/cli-run.js.map +1 -1
  60. package/dist/logclient/cli.js +2 -2
  61. package/dist/logclient/client.js +2 -2
  62. package/dist/logclient/client.js.map +1 -1
  63. package/dist/logclient/rest.js +1 -1
  64. package/dist/logclient/socket-double.d.ts +1 -1
  65. package/dist/logclient/socket-double.js +1 -1
  66. package/dist/logclient/socket.js +5 -5
  67. package/dist/logclient/socket.js.map +1 -1
  68. package/dist/logclient/time-window.d.ts +1 -1
  69. package/dist/mqttClient.d.ts +1 -1
  70. package/dist/mqttClient.js +3 -3
  71. package/dist/mqttClient.js.map +1 -1
  72. package/dist/timer-registry.d.ts +100 -0
  73. package/dist/timer-registry.js +184 -0
  74. package/dist/timer-registry.js.map +1 -0
  75. package/dist/ui/featureOptions.js +6 -6
  76. package/dist/ui/pluginConfigSession.mjs +1 -1
  77. package/dist/ui/webUi-featureOptions/effects/persist.mjs +2 -2
  78. package/dist/ui/webUi-featureOptions/effects/theme.mjs +1 -1
  79. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +1 -1
  80. package/dist/ui/webUi-featureOptions/rendering.mjs +2 -2
  81. package/dist/ui/webUi-featureOptions/selectors.mjs +3 -3
  82. package/dist/ui/webUi-featureOptions/state.mjs +72 -15
  83. package/dist/ui/webUi-featureOptions/store.mjs +1 -1
  84. package/dist/ui/webUi-featureOptions/utils.mjs +25 -1
  85. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +28 -11
  86. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +7 -0
  87. package/dist/ui/webUi-featureOptions/views/header.mjs +22 -5
  88. package/dist/ui/webUi-featureOptions/views/nav.mjs +39 -32
  89. package/dist/ui/webUi-featureOptions/views/options.mjs +13 -7
  90. package/dist/ui/webUi-featureOptions/views/search.mjs +2 -2
  91. package/dist/ui/webUi-featureOptions.mjs +109 -61
  92. package/dist/ui/webUi.mjs +48 -11
  93. package/dist/util.d.ts +118 -16
  94. package/dist/util.js +96 -14
  95. package/dist/util.js.map +1 -1
  96. package/dist/webui-loader.d.ts +80 -0
  97. package/dist/webui-loader.js +373 -0
  98. package/dist/webui-loader.js.map +1 -0
  99. package/package.json +10 -10
@@ -4,10 +4,10 @@
4
4
  */
5
5
  "use strict";
6
6
 
7
+ import { delay, errorMessage, toastError } from "./webUi-featureOptions/utils.mjs";
7
8
  import { initialState, reducer } from "./webUi-featureOptions/state.mjs";
8
9
  import { FeatureOptionsStore } from "./webUi-featureOptions/store.mjs";
9
10
  import { buildCatalogIndex } from "./featureOptions.js";
10
- import { delay } from "./webUi-featureOptions/utils.mjs";
11
11
  import { mountConnectionErrorView } from "./webUi-featureOptions/views/connectionError.mjs";
12
12
  import { mountDeviceInfoView } from "./webUi-featureOptions/views/deviceInfo.mjs";
13
13
  import { mountHeaderView } from "./webUi-featureOptions/views/header.mjs";
@@ -44,10 +44,19 @@ const FLUSH_TEARDOWN_TIMEOUT_MS = 2000;
44
44
  * @property {string} serialNumber - The unique serial number of the controller.
45
45
  */
46
46
 
47
+ /**
48
+ * The resolved shape of a `getDevices` hook: the single contract every device fetch crosses. It carries the device list and the connection outcome together, so a
49
+ * failure travels back with the response it belongs to rather than through a separate side-channel a concurrent probe could rewrite.
50
+ *
51
+ * @typedef {Object} DeviceListResult
52
+ * @property {Object[]} devices - The devices for the requested controller; empty when the probe failed or when the controller legitimately has none.
53
+ * @property {string} error - The user-facing connection-failure message: empty when the fetch succeeded, the failure text when the fetch failed and `devices` is empty.
54
+ */
55
+
47
56
  /**
48
57
  * @typedef {Object} FeatureOptionsConfig
49
58
  * @property {Function} [getControllers] - Handler to retrieve available controllers.
50
- * @property {Function} [getDevices] - Handler to retrieve devices for a controller.
59
+ * @property {(controller: (Controller|null)) => Promise<DeviceListResult>} [getDevices] - Handler resolving a controller's {@link DeviceListResult}.
51
60
  * @property {Function} [infoPanel] - Handler to display device information.
52
61
  * @property {Object} [sidebar] - Sidebar configuration options.
53
62
  * @property {string} [sidebar.controllerLabel="Controllers"] - Label for the controllers section.
@@ -68,7 +77,8 @@ const FLUSH_TEARDOWN_TIMEOUT_MS = 2000;
68
77
  *
69
78
  * Public API: constructor takes the same options shape, `show()` reveals the UI, `hide()` is the navigate-away (it flushes any pending edit, then tears down),
70
79
  * `cleanup()` is immediate destructive teardown (may drop an unsaved debounced edit; for forced/synchronous disposal), `getHomebridgeDevices()` is the default
71
- * device source. Plugins consuming the library see no change to this surface.
80
+ * device source. The device-list contract is rich: a `getDevices` hook resolves a {@link DeviceListResult} carrying both the device array and the connection
81
+ * outcome, and `getHomebridgeDevices` resolves the same shape.
72
82
  *
73
83
  * Internally, the store owns per-show state, effects own side effects, views own DOM, and the orchestrator is the lifecycle seam that boots and tears them down. The one
74
84
  * piece of state it keeps itself is #initialOptions - the revert-to-saved snapshot - which must outlive the store's per-show() reset; all else flows through the store.
@@ -79,7 +89,7 @@ const FLUSH_TEARDOWN_TIMEOUT_MS = 2000;
79
89
  * const session = await PluginConfigSession.open({ host: homebridge, name: "My Plugin" });
80
90
  * const featureOptionsUI = new webUiFeatureOptions({
81
91
  * getControllers: ({ config }) => myPlugin.controllersFrom(config),
82
- * getDevices: async (controller, { config }) => controller ? myPlugin.getDevices(controller, config) : [],
92
+ * getDevices: async (controller, { config }) => controller ? { devices: await myPlugin.getDevices(controller, config), error: "" } : { devices: [], error: "" },
83
93
  * ui: {
84
94
  * isController: (device) => device?.type === "controller",
85
95
  * validOption: (device, option) => device?.type !== "controller" || !option.name.startsWith("Video.")
@@ -187,18 +197,20 @@ export class webUiFeatureOptions {
187
197
  *
188
198
  * 1. Synchronous page-shell setup: hide schema form, update menu state, reveal the feature-options page. The user sees the layout immediately; the async I/O
189
199
  * below populates each region against the visible shell.
190
- * 2. Tear down any prior show() cycle via hide() (it flushes any pending edit before tearing down), then re-sync the session against the host config; a sync
191
- * failure toasts the error message and bails.
192
- * 3. Create the page abort controller. Every effect and view registers listeners with this signal so cleanup() tears them all down in one operation.
193
- * 4. Fire the plugin I/O requests in parallel: controllers (if configured) and the feature catalog. The plugin config is already held by the session, so there
200
+ * 2. Tear down any prior show() cycle via hide() (it flushes any pending edit before tearing down).
201
+ * 3. Create the page abort controller, clear stale containers, create the store, and mount every view. The views mount here - before any data loads, against the
202
+ * loading placeholder - so the connection-error view already exists to render a config-sync failure into the visible shell.
203
+ * 4. Re-sync the session against the host config. A read failure routes into the connection-error view (with the config-read copy) and bails, so a failed sync
204
+ * shows the retry affordance rather than stranding a blank frame; the sync also lands before getControllers and the options read below, so both see fresh config.
205
+ * 5. Fire the plugin I/O requests in parallel: controllers (if configured) and the feature catalog. The plugin config is already held by the session, so there
194
206
  * is no config fetch to overlap here - the base options come from the session's primary entry.
195
- * 5. Adopt the design tokens. Synchronous - tokens are static declarations with no I/O dependencies.
196
- * 6. Fire the theme effect, persist effect, keyboard effect in parallel. The theme effect's I/O (Bootstrap probe) runs in the background.
207
+ * 6. Adopt the design tokens (synchronous), then fire the theme, persist, and keyboard effects. The theme effect's I/O (Bootstrap probe) runs in the background.
197
208
  * 7. Once controllers resolves: if controller-based mode with empty controllers, show the no-controllers message and return.
198
- * 8. Pre-fire the devices fetch for the initial controller so it overlaps with the feature catalog.
199
- * 9. Once the feature catalog resolves: build the catalog, dispatch model:loaded, mount all views.
200
- * 10. Once devices resolve: dispatch devices:loaded. If a controller is selected but no devices were returned, fetch the error message, dispatch
201
- * connection:error, and return; otherwise set the initial scope.
209
+ * 8. Record and pre-fire the initial controller's devices fetch (a `devices:requested` mints its sequence) so it overlaps with the feature catalog.
210
+ * 9. Once the feature catalog resolves: build the catalog and dispatch model:loaded - the mounted views transition off their loading placeholder and render.
211
+ * 10. Once devices resolve: dispatch devices:loaded carrying the outcome and its sequence. The reducer applies it only when it still answers the pending request and
212
+ * folds a fetch failure into the connection-error transition; the orchestrator gates its follow-ups on that verdict - a superseded outcome or a connection-error
213
+ * status returns without revealing, otherwise it sets the initial scope.
202
214
  * 11. Reveal regions that views render into.
203
215
  *
204
216
  * @param {import("./pluginConfigSession.mjs").PluginConfigSession} session - The config session supplied by the orchestrator; the page's single source of
@@ -220,20 +232,6 @@ export class webUiFeatureOptions {
220
232
  // the menu or the connection-error retry must drain the previous cycle's debounced edit before this cycle's store replaces it.
221
233
  await this.hide();
222
234
 
223
- // Re-read the host config into the session before the page renders against it. show() is the single entry chokepoint (launch, first-run, the menu, and the
224
- // connection-error retry), so re-syncing here makes "every show is fresh" an unconditional invariant: an edit made in the Settings tab while this page was hidden
225
- // is reflected on return rather than rendering against a frozen snapshot. The sync lands before getControllers reads session.platform and before the options read
226
- // below, so both derive from the re-read config. A read failure surfaces as a toast and bails rather than rejecting into a dropped-promise menu handler.
227
- try {
228
-
229
- await this.#session.sync();
230
- } catch(err) {
231
-
232
- homebridge.toast.error(err?.message ?? String(err), "Error");
233
-
234
- return;
235
- }
236
-
237
235
  // Fresh page-level abort controller for this show() cycle.
238
236
  this.#pageAbort = new AbortController();
239
237
 
@@ -245,6 +243,32 @@ export class webUiFeatureOptions {
245
243
  // Initialize the store with empty state. The reducer transitions through loading -> ready once model:loaded dispatches.
246
244
  this.#store = new FeatureOptionsStore({ initialState: initialState(), reducer });
247
245
 
246
+ // Mount every view before any data loads. Each view registers its effects against the loading placeholder and yields until model:loaded fires, so mounting
247
+ // here renders nothing yet - but the connection-error view exists from this point on, so a config-sync failure below renders its retry affordance instead of
248
+ // stranding a blank frame. Each mount is a no-op if its required page element is missing, so the orchestrator does not need to validate the page skeleton up front.
249
+ this.#mountViews(signal);
250
+
251
+ // Re-read the host config into the session before the page renders against it. show() is the single entry chokepoint (launch, first-run, the menu, and the
252
+ // connection-error retry), so re-syncing here makes "every show is fresh" an unconditional guarantee: an edit made in the Settings tab while this page was hidden
253
+ // is reflected on return rather than rendering against a frozen snapshot. The sync lands before getControllers reads session.platform and before the options read
254
+ // below, so both derive from the re-read config. A read failure routes into the already-mounted connection-error view rather than a bare toast: the store and
255
+ // views exist by now, so the page shows the retry affordance instead of stranding a blank frame.
256
+ try {
257
+
258
+ await this.#session.sync();
259
+ } catch(err) {
260
+
261
+ this.#store.dispatch({
262
+
263
+ guidance: "Retry once the Homebridge server is reachable again.",
264
+ headline: "Unable to read the plugin configuration.",
265
+ message: errorMessage(err),
266
+ type: "connection:error"
267
+ });
268
+
269
+ return;
270
+ }
271
+
248
272
  // Fire every independent I/O in parallel. The independent sources: controllers (optional), the /getOptions catalog, and the Homebridge lighting mode (via the
249
273
  // theme effect's host). The plugin config is not fetched here - the session already holds it - so getControllers receives the injected platform config rather
250
274
  // than reaching for it. None depend on each other; firing them concurrently means total wall-clock time is bounded by the slowest.
@@ -292,6 +316,12 @@ export class webUiFeatureOptions {
292
316
  }
293
317
 
294
318
  const initialController = controllers?.[0] ?? null;
319
+
320
+ // Record this fetch at the store's chokepoint before firing it, then read back the minted sequence - the store's ticket for this fetch. The sequence, not the
321
+ // controller, is the fetch identity, so a controller click racing this initial fetch resolves last-request-wins at the reducer.
322
+ this.#store.dispatch({ controllerId: initialController?.serialNumber ?? null, type: "devices:requested" });
323
+
324
+ const devicesSeq = this.#store.state.devicesRequest.seq;
295
325
  const devicesPromise = this.#devicesFor(initialController);
296
326
 
297
327
  // Wait for the feature catalog. Build the catalog (catalog index + validators) and dispatch model:loaded so the store transitions to "ready" and views can mount
@@ -329,9 +359,6 @@ export class webUiFeatureOptions {
329
359
  type: "model:loaded"
330
360
  });
331
361
 
332
- // Mount every view. Each mount is a no-op if its required page element is missing, so the orchestrator does not need to validate the page skeleton up front.
333
- this.#mountViews(signal);
334
-
335
362
  // Wait for the theme's matchMedia listener registration before any user interaction can trigger a theme change. By this point, themeInitPromise has almost
336
363
  // always already resolved - it ran in parallel with every other fetch above.
337
364
  await themeInitPromise;
@@ -342,37 +369,35 @@ export class webUiFeatureOptions {
342
369
  }
343
370
 
344
371
  // Wait for devices. The fetch overlapped with config + features; typically already resolved by now.
345
- const devices = await devicesPromise;
372
+ const { devices, error } = await devicesPromise;
346
373
 
347
374
  if(signal.aborted) {
348
375
 
349
376
  return;
350
377
  }
351
378
 
352
- this.#store.dispatch({ controllerId: initialController?.serialNumber ?? null, devices: devices ?? [], type: "devices:loaded" });
353
-
354
- // Connection-error short-circuit: controller-based mode with a selected controller but no devices returned means the controller is configured but unreachable.
355
- // Fetch the user-facing error message from the host and dispatch connection:error so the connection-error view takes over the header. Do not transition the
356
- // scope to a device-view (there are no devices); leave it at global so any subsequent retry re-shows from a known scope.
357
- if((initialController !== null) && (devices.length === 0)) {
379
+ this.#store.dispatch({ controllerId: initialController?.serialNumber ?? null, devices, error, seq: devicesSeq, type: "devices:loaded" });
358
380
 
359
- const message = String(await homebridge.request("/getErrorMessage") ?? "");
381
+ // Gate every follow-up on the reducer's own verdict: my outcome applied only when the sequence I carried is the one the reducer recorded. A controller click that
382
+ // raced this initial fetch would have superseded it - its outcome owns the store, and this stale continuation must neither reveal the page over it nor overwrite
383
+ // its scope.
384
+ if(this.#store.state.devicesAppliedSeq !== devicesSeq) {
360
385
 
361
- if(signal.aborted) {
362
-
363
- return;
364
- }
386
+ return;
387
+ }
365
388
 
366
- this.#store.dispatch({ message, type: "connection:error" });
389
+ // My outcome applied and it turned the store to connection-error: a selected controller whose probe reported a failure. The failure message travelled back on the
390
+ // DeviceListResult rather than through a separate request, and the reducer folded it into the connection-error transition. The connection-error view reveals
391
+ // #headerInfo itself when it renders the error block (it is the sole owner of the error display, content and reveal together), so the orchestrator only returns
392
+ // here. The sidebar, search panel, and config table stay hidden - hide() set them so at show() start and the success-path revealRegions() never runs on this
393
+ // branch - because the user has no devices to navigate to.
394
+ if(this.#store.state.status.kind === "connection-error") {
367
395
 
368
- // The connection-error view reveals #headerInfo itself when it renders the error block (it is the sole owner of the error display, content and reveal together),
369
- // so the orchestrator only transitions state here and returns. The sidebar, search panel, and config table stay hidden - hide() set them so at show() start and
370
- // the success-path revealRegions() never runs on this branch - because the user has no devices to navigate to.
371
396
  return;
372
397
  }
373
398
 
374
- // Set the initial scope. Controller-based mode lands on the first controller's controller-as-device entry (devices[0]). Device-only mode lands on global so
375
- // the user sees the global options first.
399
+ // Set the initial scope. My outcome applied, so the local `devices` is the applied list. Controller-based mode lands on the first controller's controller-as-device
400
+ // entry (devices[0]). Device-only mode lands on global so the user sees the global options first.
376
401
  if((initialController !== null) && (devices.length > 0)) {
377
402
 
378
403
  this.#store.dispatch({
@@ -488,7 +513,9 @@ export class webUiFeatureOptions {
488
513
  * Used as the default `getDevices`, it is extracted unbound and later invoked with `#config` as the receiver, so its body must never reference `this` - it reads only
489
514
  * the global `homebridge` object. A future edit that needs instance state must bind it explicitly (or stop using it as the bare default).
490
515
  *
491
- * @returns {Promise<Device[]>} The list of devices sorted alphabetically by name.
516
+ * The device-only default always succeeds against the local accessory cache, so the {@link DeviceListResult} it resolves carries an empty error.
517
+ *
518
+ * @returns {Promise<DeviceListResult>} The device list sorted alphabetically by name, paired with an empty error.
492
519
  * @public
493
520
  */
494
521
  async getHomebridgeDevices() {
@@ -511,7 +538,7 @@ export class webUiFeatureOptions {
511
538
  });
512
539
  }
513
540
 
514
- return devices.toSorted((a, b) => (a.name ?? "").toLowerCase().localeCompare((b.name ?? "").toLowerCase()));
541
+ return { devices: devices.toSorted((a, b) => (a.name ?? "").toLowerCase().localeCompare((b.name ?? "").toLowerCase())), error: "" };
515
542
  }
516
543
 
517
544
  // Mount every view against the active store and page DOM. Each view registers its own listeners with the page signal; nothing here references the views after
@@ -534,9 +561,16 @@ export class webUiFeatureOptions {
534
561
 
535
562
  // Retry routes through show(), which owns teardown: its internal `await this.hide()` flushes any debounced edit before aborting the page signal, so a retry
536
563
  // cannot drop a pending write. We deliberately do not call cleanup() here - cleanup() aborts the signal without flushing, which is exactly the drop we avoid.
564
+ // The retry button fires this as `void onRetry()`, so a rejection is otherwise unobserved; the try/catch surfaces a failed re-show as an error toast instead.
537
565
  onRetry: async () => {
538
566
 
539
- await this.show(this.#session);
567
+ try {
568
+
569
+ await this.show(this.#session);
570
+ } catch(err) {
571
+
572
+ toastError(err);
573
+ }
540
574
  },
541
575
  retryDelayMs: this.#config.controllerRetryEnableDelayMs,
542
576
  root: headerInfo,
@@ -565,8 +599,10 @@ export class webUiFeatureOptions {
565
599
 
566
600
  if(configTable) {
567
601
 
568
- // The localStorage namespace key is the Homebridge platform identifier - the primary entry's `platform` field, read from the session.
569
- mountOptionsView({ configTable, platform: this.#session?.platform?.platform, signal, store });
602
+ // The localStorage namespace key is the Homebridge platform identifier - the primary entry's `platform` field. Passed as a thunk, not a frozen string: the
603
+ // views mount before the session re-syncs, so the options view reads the identifier through the thunk inside its model:loaded effect (post-sync) rather
604
+ // than capturing a possibly pre-sync value here.
605
+ mountOptionsView({ configTable, platform: () => this.#session?.platform?.platform, signal, store });
570
606
  }
571
607
 
572
608
  if(controllersContainer && devicesContainer) {
@@ -574,7 +610,6 @@ export class webUiFeatureOptions {
574
610
  mountNavView({
575
611
 
576
612
  getDevices: (controller) => this.#devicesFor(controller),
577
- host: homebridge,
578
613
  labelControllers: this.#config.labelControllers,
579
614
  labelDevices: this.#config.labelDevices,
580
615
  rootControllers: controllersContainer,
@@ -585,12 +620,24 @@ export class webUiFeatureOptions {
585
620
  }
586
621
  }
587
622
 
588
- // Resolve the device list for a controller, injecting the live platform config the plugin's getDevices needs to recover the controller's credentials. Centralizing
589
- // the injection here means both the initial fetch in show() and the on-click fetch in the nav view call the same config-fed seam, and the config is read fresh from
590
- // the session on every call - never captured - so a credential change is always reflected. The default device-only getDevices ignores the injected config.
591
- #devicesFor(controller) {
623
+ // Resolve a controller's DeviceListResult, injecting the live platform config the plugin's getDevices needs to recover the controller's credentials. This is the
624
+ // single seam every device fetch crosses - the initial fetch in show() and the on-click fetch in the nav view both route through it - and the config is read fresh
625
+ // from the session on every call, never captured, so a credential change is always reflected. The default device-only getDevices ignores the injected config.
626
+ //
627
+ // The full rich contract is enforced here with a fail-fast guard: the hook must resolve an object carrying a `devices` array and a string `error`. The error half
628
+ // is what guarantees every downstream reader (the connection-error view's DOM construction, whose createElement child loop passes a non-string message straight to
629
+ // appendChild) receives a string. A resolved value that does not match trips a TypeError naming the contract, so a shape mistake surfaces loudly at the seam rather
630
+ // than as a corrupted render deeper in.
631
+ async #devicesFor(controller) {
632
+
633
+ const result = await this.#config.getDevices(controller, { config: this.#session?.platform });
634
+
635
+ if(!result || !Array.isArray(result.devices) || (typeof result.error !== "string")) {
636
+
637
+ throw new TypeError("getDevices must resolve to { devices, error }.");
638
+ }
592
639
 
593
- return this.#config.getDevices(controller, { config: this.#session?.platform });
640
+ return result;
594
641
  }
595
642
  }
596
643
 
@@ -664,7 +711,8 @@ const sameOptionsSet = (a, b) => {
664
711
  };
665
712
 
666
713
  // Show the "no controllers configured" helper text when the plugin operates in controller-based mode but getControllers returns an empty result. Replaces the
667
- // header content in place; the orchestrator returns from show() without dispatching model:loaded so views never mount.
714
+ // header content in place; the orchestrator returns from show() without dispatching model:loaded, so the already-mounted views stay on their loading placeholder and
715
+ // never render over this message.
668
716
  const showNoControllersMessage = () => {
669
717
 
670
718
  const headerInfo = document.getElementById("headerInfo");
package/dist/ui/webUi.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  "use strict";
6
6
 
7
7
  import { PluginConfigSession } from "./pluginConfigSession.mjs";
8
+ import { toastError } from "./webUi-featureOptions/utils.mjs";
8
9
  import { webUiFeatureOptions } from "./webUi-featureOptions.mjs";
9
10
 
10
11
  /**
@@ -80,13 +81,17 @@ export class webUi {
80
81
  await this.#launchWebUI();
81
82
  } catch(err) {
82
83
 
83
- // Outermost user-facing diagnostic seam in the webUI. Caller-supplied first-run handlers and other extension points can throw any shape (strings, plain
84
- // objects, primitives), so normalize before reaching the toast: `err?.message` extracts the message field when present, the nullish coalesce falls back
85
- // to a string coercion of the whole value otherwise. This keeps the toast text useful regardless of what bubbled out of `#launchWebUI`.
86
- homebridge.toast.error(err?.message ?? String(err), "Error");
84
+ // Outermost user-facing diagnostic seam in the webUI. Caller-supplied first-run handlers and other extension points can throw any shape, so the shared
85
+ // toastError normalization extracts a useful message regardless of what bubbled out of `#launchWebUI`.
86
+ toastError(err);
87
87
  } finally {
88
88
 
89
89
  homebridge.hideSpinner();
90
+
91
+ // Stand the boot monitor down. Once show() settles the app owns the surface either way - on success the UI rendered, on failure the toast above displayed the
92
+ // diagnostic - so any earlier boot-phase error the monitor may have caught was non-fatal, and it should retract any panel it raised. The optional chain tolerates
93
+ // a stamped region that carries no boot monitor.
94
+ globalThis.webUiBoot?.ready?.();
90
95
  }
91
96
  }
92
97
 
@@ -94,7 +99,10 @@ export class webUi {
94
99
  * Show the first-run user experience.
95
100
  *
96
101
  * Wires the submit button to run the caller-supplied submit handler, swap the page from first-run to feature-options, and hand off to the feature-options view.
97
- * The save button stays disabled until the user completes the first-run flow so a partially-configured plugin cannot be written back to disk.
102
+ * The save button stays disabled until the user completes the first-run flow so a partially-configured plugin cannot be written back to disk. A submit failure
103
+ * surfaces as an error toast, and where the failure landed decides what the user is left looking at: a rejected submit throws before the page swap and leaves the
104
+ * first-run page fully visible for another attempt, while a failure during the feature-options handoff after a successful submit leaves the main shell visible with
105
+ * the menu still usable for recovery.
98
106
  *
99
107
  * @returns {Promise<void>}
100
108
  * @private
@@ -134,6 +142,13 @@ export class webUi {
134
142
  await this.featureOptions.show(this.#session);
135
143
 
136
144
  homebridge.enableSaveButton();
145
+ } catch(err) {
146
+
147
+ // A first-run submit can throw from two places, and where it threw decides what the user is left looking at. A rejected onSubmit (a failed login or
148
+ // configuration validation) throws before the page swap, so the first-run page stays fully visible for another attempt. A rejection from
149
+ // featureOptions.show() after a successful submit throws after the swap, so the main shell is visible with the menu still usable for recovery. In both cases
150
+ // the toast is the diagnostic, and the finally below brings the spinner down.
151
+ toastError(err);
137
152
  } finally {
138
153
 
139
154
  homebridge.hideSpinner();
@@ -143,6 +158,27 @@ export class webUi {
143
158
  document.getElementById("pageFirstRun").style.display = "block";
144
159
  }
145
160
 
161
+ /**
162
+ * Show the feature-options tab from the menu.
163
+ *
164
+ * The menuFeatureOptions button re-enters the feature-options view. `featureOptions.show()` can reject - a plugin `getDevices` hook that resolves the wrong shape
165
+ * trips the device-list contract guard, for one - and the click listener drops the returned promise, so this method brackets the re-entry in a try/catch that
166
+ * surfaces a failed re-show as an error toast rather than an unobserved rejection.
167
+ *
168
+ * @returns {Promise<void>}
169
+ * @private
170
+ */
171
+ async #showFeatureOptions() {
172
+
173
+ try {
174
+
175
+ await this.featureOptions.show(this.#session);
176
+ } catch(err) {
177
+
178
+ toastError(err);
179
+ }
180
+ }
181
+
146
182
  /**
147
183
  * Show the main plugin configuration tab.
148
184
  *
@@ -228,13 +264,14 @@ export class webUi {
228
264
  // later reader sees.
229
265
  this.#session = await PluginConfigSession.open({ host: homebridge, name: this.#name });
230
266
 
231
- // Menu click listeners use a uniform shape: an arrow expression that calls the handler and returns its result. addEventListener discards the return value, so an
232
- // async handler's promise is dropped either way; wrapping `featureOptions.show()` in `async () => await ...` would add a microtask hop and break the visual
233
- // symmetry across the three sibling registrations for no behavioral benefit. All three handlers are async (show() syncs; #showSettings / #showSupport await
234
- // the navigate-away flush before revealing the next tab), and the dropped promise is a deliberate fire-and-forget: each handler's own try/finally reveals the tab
235
- // and drains the spinner on every path, and the flush drain's failure surfaces internally via persist:failed's toast - so there is no unobserved rejection here.
267
+ // Menu click listeners use a uniform shape: an arrow expression that calls a handler and returns its result. addEventListener discards the return value, so each
268
+ // async handler's promise is dropped; the handlers own their error handling so the drop carries no unobserved rejection. #showFeatureOptions wraps
269
+ // featureOptions.show() in a try/catch that toasts a failed re-entry - the show pipeline can reject (a plugin getDevices hook that resolves the wrong shape trips
270
+ // the device-list contract guard, for one), and without the wrapper that rejection would surface nowhere. #showSettings and #showSupport each bracket their
271
+ // navigate-away flush in a try/finally that reveals the next tab and drains the spinner on every path (the flush drain's own failure surfaces via persist:failed's
272
+ // toast).
236
273
  document.getElementById("menuHome").addEventListener("click", () => this.#showSupport());
237
- document.getElementById("menuFeatureOptions").addEventListener("click", () => this.featureOptions.show(this.#session));
274
+ document.getElementById("menuFeatureOptions").addEventListener("click", () => this.#showFeatureOptions());
238
275
  document.getElementById("menuSettings").addEventListener("click", () => this.#showSettings());
239
276
 
240
277
  // The caller's first-run gate decides routing against the injected platform config. No separate "is there any config?" test is needed: a plugin with a first-run