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.
- package/build/tsconfig.json +15 -0
- package/dist/backpressure.d.ts +1 -1
- package/dist/backpressure.js +4 -4
- package/dist/backpressure.js.map +1 -1
- package/dist/cli/index.d.ts +93 -5
- package/dist/cli/index.js +296 -30
- package/dist/cli/index.js.map +1 -1
- package/dist/clock-double.js +2 -2
- package/dist/clock-double.js.map +1 -1
- package/dist/disposable-stack.d.ts +59 -0
- package/dist/disposable-stack.js +155 -0
- package/dist/disposable-stack.js.map +1 -0
- package/dist/docChrome.d.ts +263 -0
- package/dist/docChrome.js +364 -0
- package/dist/docChrome.js.map +1 -0
- package/dist/featureOptions-docs.d.ts +2 -2
- package/dist/featureOptions-docs.js +1 -1
- package/dist/featureOptions.d.ts +5 -5
- package/dist/featureOptions.js +6 -6
- package/dist/ffmpeg/exec.d.ts +2 -2
- package/dist/ffmpeg/exec.js +2 -2
- package/dist/ffmpeg/fmp4-builders.d.ts +77 -0
- package/dist/ffmpeg/fmp4-builders.js +163 -0
- package/dist/ffmpeg/fmp4-builders.js.map +1 -0
- package/dist/ffmpeg/index.d.ts +1 -0
- package/dist/ffmpeg/index.js +1 -0
- package/dist/ffmpeg/index.js.map +1 -1
- package/dist/ffmpeg/mp4-assembler.d.ts +45 -4
- package/dist/ffmpeg/mp4-assembler.js +45 -8
- package/dist/ffmpeg/mp4-assembler.js.map +1 -1
- package/dist/ffmpeg/mp4-parser.d.ts +1 -1
- package/dist/ffmpeg/mp4-parser.js +1 -1
- package/dist/ffmpeg/options.d.ts +17 -0
- package/dist/ffmpeg/options.js +50 -25
- package/dist/ffmpeg/options.js.map +1 -1
- package/dist/ffmpeg/process.d.ts +1 -1
- package/dist/ffmpeg/process.js +4 -4
- package/dist/ffmpeg/process.js.map +1 -1
- package/dist/ffmpeg/record.d.ts +26 -1
- package/dist/ffmpeg/record.js +12 -0
- package/dist/ffmpeg/record.js.map +1 -1
- package/dist/ffmpeg/recording-process-double.d.ts +13 -0
- package/dist/ffmpeg/recording-process-double.js +22 -0
- package/dist/ffmpeg/recording-process-double.js.map +1 -1
- package/dist/ffmpeg/rtp-parser.d.ts +1 -1
- package/dist/ffmpeg/rtp-parser.js +1 -1
- package/dist/ffmpeg/rtp.d.ts +4 -4
- package/dist/ffmpeg/rtp.js +7 -6
- package/dist/ffmpeg/rtp.js.map +1 -1
- package/dist/ffmpeg/stream.js +1 -1
- package/dist/ffmpeg/stream.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/logclient/auth.d.ts +3 -3
- package/dist/logclient/auth.js +2 -2
- package/dist/logclient/auth.js.map +1 -1
- package/dist/logclient/cli-run.js +2 -2
- package/dist/logclient/cli-run.js.map +1 -1
- package/dist/logclient/cli.js +2 -2
- package/dist/logclient/client.js +2 -2
- package/dist/logclient/client.js.map +1 -1
- package/dist/logclient/rest.js +1 -1
- package/dist/logclient/socket-double.d.ts +1 -1
- package/dist/logclient/socket-double.js +1 -1
- package/dist/logclient/socket.js +5 -5
- package/dist/logclient/socket.js.map +1 -1
- package/dist/logclient/time-window.d.ts +1 -1
- package/dist/mqttClient.d.ts +1 -1
- package/dist/mqttClient.js +3 -3
- package/dist/mqttClient.js.map +1 -1
- package/dist/timer-registry.d.ts +100 -0
- package/dist/timer-registry.js +184 -0
- package/dist/timer-registry.js.map +1 -0
- package/dist/ui/featureOptions.js +6 -6
- package/dist/ui/pluginConfigSession.mjs +1 -1
- package/dist/ui/webUi-featureOptions/effects/persist.mjs +2 -2
- package/dist/ui/webUi-featureOptions/effects/theme.mjs +1 -1
- package/dist/ui/webUi-featureOptions/effects/tokens.mjs +1 -1
- package/dist/ui/webUi-featureOptions/rendering.mjs +2 -2
- package/dist/ui/webUi-featureOptions/selectors.mjs +3 -3
- package/dist/ui/webUi-featureOptions/state.mjs +72 -15
- package/dist/ui/webUi-featureOptions/store.mjs +1 -1
- package/dist/ui/webUi-featureOptions/utils.mjs +25 -1
- package/dist/ui/webUi-featureOptions/views/connectionError.mjs +28 -11
- package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +7 -0
- package/dist/ui/webUi-featureOptions/views/header.mjs +22 -5
- package/dist/ui/webUi-featureOptions/views/nav.mjs +39 -32
- package/dist/ui/webUi-featureOptions/views/options.mjs +13 -7
- package/dist/ui/webUi-featureOptions/views/search.mjs +2 -2
- package/dist/ui/webUi-featureOptions.mjs +109 -61
- package/dist/ui/webUi.mjs +48 -11
- package/dist/util.d.ts +118 -16
- package/dist/util.js +96 -14
- package/dist/util.js.map +1 -1
- package/dist/webui-loader.d.ts +80 -0
- package/dist/webui-loader.js +373 -0
- package/dist/webui-loader.js.map +1 -0
- 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 {
|
|
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.
|
|
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)
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* 4.
|
|
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
|
-
*
|
|
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.
|
|
199
|
-
* 9. Once the feature catalog resolves: build the catalog
|
|
200
|
-
* 10. Once devices resolve: dispatch devices:loaded
|
|
201
|
-
* connection
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
365
388
|
|
|
366
|
-
|
|
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
|
|
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
|
-
* @
|
|
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
|
-
|
|
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,
|
|
569
|
-
|
|
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
|
|
589
|
-
//
|
|
590
|
-
// the session on every call
|
|
591
|
-
|
|
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
|
|
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
|
|
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
|
|
84
|
-
//
|
|
85
|
-
|
|
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
|
|
232
|
-
// async handler's promise is dropped
|
|
233
|
-
//
|
|
234
|
-
// the
|
|
235
|
-
// and drains the spinner on every path
|
|
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
|
|
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
|