homebridge-plugin-utils 2.0.0 → 2.1.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/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 +73 -2
- package/dist/cli/index.js +221 -25
- 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 +260 -0
- package/dist/docChrome.js +361 -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/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.js +3 -3
- 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 +1 -0
- package/dist/index.js +1 -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/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 +6 -6
- package/dist/ui/webUi-featureOptions/store.mjs +1 -1
- package/dist/ui/webUi-featureOptions/utils.mjs +14 -1
- package/dist/ui/webUi-featureOptions/views/connectionError.mjs +1 -1
- package/dist/ui/webUi-featureOptions/views/nav.mjs +42 -28
- package/dist/ui/webUi-featureOptions/views/options.mjs +2 -2
- package/dist/ui/webUi-featureOptions/views/search.mjs +2 -2
- package/dist/ui/webUi-featureOptions.mjs +57 -32
- package/dist/ui/webUi.mjs +43 -11
- package/dist/util.d.ts +89 -12
- package/dist/util.js +62 -14
- package/dist/util.js.map +1 -1
- package/package.json +9 -9
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
"use strict";
|
|
6
6
|
|
|
7
|
+
import { delay, 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.")
|
|
@@ -197,8 +207,8 @@ export class webUiFeatureOptions {
|
|
|
197
207
|
* 7. Once controllers resolves: if controller-based mode with empty controllers, show the no-controllers message and return.
|
|
198
208
|
* 8. Pre-fire the devices fetch for the initial controller so it overlaps with the feature catalog.
|
|
199
209
|
* 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
|
|
201
|
-
*
|
|
210
|
+
* 10. Once devices resolve: dispatch devices:loaded. If a controller is selected and the result carried a connection-failure error, dispatch connection:error
|
|
211
|
+
* with that message and return; otherwise set the initial scope.
|
|
202
212
|
* 11. Reveal regions that views render into.
|
|
203
213
|
*
|
|
204
214
|
* @param {import("./pluginConfigSession.mjs").PluginConfigSession} session - The config session supplied by the orchestrator; the page's single source of
|
|
@@ -221,15 +231,15 @@ export class webUiFeatureOptions {
|
|
|
221
231
|
await this.hide();
|
|
222
232
|
|
|
223
233
|
// 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
|
|
234
|
+
// 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
|
|
225
235
|
// 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
|
|
236
|
+
// below, so both derive from the re-read config. A read failure surfaces as a toast and bails, so the page never renders against a config the session could not read.
|
|
227
237
|
try {
|
|
228
238
|
|
|
229
239
|
await this.#session.sync();
|
|
230
240
|
} catch(err) {
|
|
231
241
|
|
|
232
|
-
|
|
242
|
+
toastError(err);
|
|
233
243
|
|
|
234
244
|
return;
|
|
235
245
|
}
|
|
@@ -342,28 +352,23 @@ export class webUiFeatureOptions {
|
|
|
342
352
|
}
|
|
343
353
|
|
|
344
354
|
// Wait for devices. The fetch overlapped with config + features; typically already resolved by now.
|
|
345
|
-
const devices = await devicesPromise;
|
|
355
|
+
const { devices, error } = await devicesPromise;
|
|
346
356
|
|
|
347
357
|
if(signal.aborted) {
|
|
348
358
|
|
|
349
359
|
return;
|
|
350
360
|
}
|
|
351
361
|
|
|
352
|
-
this.#store.dispatch({ controllerId: initialController?.serialNumber ?? null, devices
|
|
362
|
+
this.#store.dispatch({ controllerId: initialController?.serialNumber ?? null, devices, type: "devices:loaded" });
|
|
353
363
|
|
|
354
|
-
// Connection-error short-circuit: controller
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if(signal.aborted) {
|
|
362
|
-
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
364
|
+
// Connection-error short-circuit: a selected controller whose probe reported a failure. The failure message arrives with the device-list response - it travels
|
|
365
|
+
// back on the DeviceListResult rather than through a separate request - so a non-empty error is the failure signal and dispatching connection:error hands the
|
|
366
|
+
// header to the connection-error view. A zero-device result with an empty error is a legitimately empty controller and falls through to the scope logic below,
|
|
367
|
+
// never the connection-error view. Do not transition the scope to a device-view here (there are no devices); leave it at global so any subsequent retry re-shows
|
|
368
|
+
// from a known scope.
|
|
369
|
+
if((initialController !== null) && error.length) {
|
|
365
370
|
|
|
366
|
-
this.#store.dispatch({ message, type: "connection:error" });
|
|
371
|
+
this.#store.dispatch({ message: error, type: "connection:error" });
|
|
367
372
|
|
|
368
373
|
// 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
374
|
// 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
|
|
@@ -488,7 +493,9 @@ export class webUiFeatureOptions {
|
|
|
488
493
|
* 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
494
|
* the global `homebridge` object. A future edit that needs instance state must bind it explicitly (or stop using it as the bare default).
|
|
490
495
|
*
|
|
491
|
-
* @
|
|
496
|
+
* The device-only default always succeeds against the local accessory cache, so the {@link DeviceListResult} it resolves carries an empty error.
|
|
497
|
+
*
|
|
498
|
+
* @returns {Promise<DeviceListResult>} The device list sorted alphabetically by name, paired with an empty error.
|
|
492
499
|
* @public
|
|
493
500
|
*/
|
|
494
501
|
async getHomebridgeDevices() {
|
|
@@ -511,7 +518,7 @@ export class webUiFeatureOptions {
|
|
|
511
518
|
});
|
|
512
519
|
}
|
|
513
520
|
|
|
514
|
-
return devices.toSorted((a, b) => (a.name ?? "").toLowerCase().localeCompare((b.name ?? "").toLowerCase()));
|
|
521
|
+
return { devices: devices.toSorted((a, b) => (a.name ?? "").toLowerCase().localeCompare((b.name ?? "").toLowerCase())), error: "" };
|
|
515
522
|
}
|
|
516
523
|
|
|
517
524
|
// 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 +541,16 @@ export class webUiFeatureOptions {
|
|
|
534
541
|
|
|
535
542
|
// 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
543
|
// 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.
|
|
544
|
+
// 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
545
|
onRetry: async () => {
|
|
538
546
|
|
|
539
|
-
|
|
547
|
+
try {
|
|
548
|
+
|
|
549
|
+
await this.show(this.#session);
|
|
550
|
+
} catch(err) {
|
|
551
|
+
|
|
552
|
+
toastError(err);
|
|
553
|
+
}
|
|
540
554
|
},
|
|
541
555
|
retryDelayMs: this.#config.controllerRetryEnableDelayMs,
|
|
542
556
|
root: headerInfo,
|
|
@@ -574,7 +588,6 @@ export class webUiFeatureOptions {
|
|
|
574
588
|
mountNavView({
|
|
575
589
|
|
|
576
590
|
getDevices: (controller) => this.#devicesFor(controller),
|
|
577
|
-
host: homebridge,
|
|
578
591
|
labelControllers: this.#config.labelControllers,
|
|
579
592
|
labelDevices: this.#config.labelDevices,
|
|
580
593
|
rootControllers: controllersContainer,
|
|
@@ -585,12 +598,24 @@ export class webUiFeatureOptions {
|
|
|
585
598
|
}
|
|
586
599
|
}
|
|
587
600
|
|
|
588
|
-
// Resolve
|
|
589
|
-
//
|
|
590
|
-
// the session on every call
|
|
591
|
-
|
|
601
|
+
// Resolve a controller's DeviceListResult, injecting the live platform config the plugin's getDevices needs to recover the controller's credentials. This is the
|
|
602
|
+
// 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
|
|
603
|
+
// from the session on every call, never captured, so a credential change is always reflected. The default device-only getDevices ignores the injected config.
|
|
604
|
+
//
|
|
605
|
+
// 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
|
|
606
|
+
// is what guarantees every downstream reader (the connection-error view's DOM construction, whose createElement child loop passes a non-string message straight to
|
|
607
|
+
// 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
|
|
608
|
+
// than as a corrupted render deeper in.
|
|
609
|
+
async #devicesFor(controller) {
|
|
610
|
+
|
|
611
|
+
const result = await this.#config.getDevices(controller, { config: this.#session?.platform });
|
|
612
|
+
|
|
613
|
+
if(!result || !Array.isArray(result.devices) || (typeof result.error !== "string")) {
|
|
614
|
+
|
|
615
|
+
throw new TypeError("getDevices must resolve to { devices, error }.");
|
|
616
|
+
}
|
|
592
617
|
|
|
593
|
-
return
|
|
618
|
+
return result;
|
|
594
619
|
}
|
|
595
620
|
}
|
|
596
621
|
|
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,10 +81,9 @@ 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();
|
|
@@ -94,7 +94,10 @@ export class webUi {
|
|
|
94
94
|
* Show the first-run user experience.
|
|
95
95
|
*
|
|
96
96
|
* 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.
|
|
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. A submit failure
|
|
98
|
+
* 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
|
|
99
|
+
* 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
|
|
100
|
+
* the menu still usable for recovery.
|
|
98
101
|
*
|
|
99
102
|
* @returns {Promise<void>}
|
|
100
103
|
* @private
|
|
@@ -134,6 +137,13 @@ export class webUi {
|
|
|
134
137
|
await this.featureOptions.show(this.#session);
|
|
135
138
|
|
|
136
139
|
homebridge.enableSaveButton();
|
|
140
|
+
} catch(err) {
|
|
141
|
+
|
|
142
|
+
// 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
|
|
143
|
+
// configuration validation) throws before the page swap, so the first-run page stays fully visible for another attempt. A rejection from
|
|
144
|
+
// 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
|
|
145
|
+
// the toast is the diagnostic, and the finally below brings the spinner down.
|
|
146
|
+
toastError(err);
|
|
137
147
|
} finally {
|
|
138
148
|
|
|
139
149
|
homebridge.hideSpinner();
|
|
@@ -143,6 +153,27 @@ export class webUi {
|
|
|
143
153
|
document.getElementById("pageFirstRun").style.display = "block";
|
|
144
154
|
}
|
|
145
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Show the feature-options tab from the menu.
|
|
158
|
+
*
|
|
159
|
+
* The menuFeatureOptions button re-enters the feature-options view. `featureOptions.show()` can reject - a plugin `getDevices` hook that resolves the wrong shape
|
|
160
|
+
* 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
|
|
161
|
+
* surfaces a failed re-show as an error toast rather than an unobserved rejection.
|
|
162
|
+
*
|
|
163
|
+
* @returns {Promise<void>}
|
|
164
|
+
* @private
|
|
165
|
+
*/
|
|
166
|
+
async #showFeatureOptions() {
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
|
|
170
|
+
await this.featureOptions.show(this.#session);
|
|
171
|
+
} catch(err) {
|
|
172
|
+
|
|
173
|
+
toastError(err);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
146
177
|
/**
|
|
147
178
|
* Show the main plugin configuration tab.
|
|
148
179
|
*
|
|
@@ -228,13 +259,14 @@ export class webUi {
|
|
|
228
259
|
// later reader sees.
|
|
229
260
|
this.#session = await PluginConfigSession.open({ host: homebridge, name: this.#name });
|
|
230
261
|
|
|
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
|
|
262
|
+
// 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
|
|
263
|
+
// async handler's promise is dropped; the handlers own their error handling so the drop carries no unobserved rejection. #showFeatureOptions wraps
|
|
264
|
+
// 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
|
|
265
|
+
// the device-list contract guard, for one), and without the wrapper that rejection would surface nowhere. #showSettings and #showSupport each bracket their
|
|
266
|
+
// 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
|
|
267
|
+
// toast).
|
|
236
268
|
document.getElementById("menuHome").addEventListener("click", () => this.#showSupport());
|
|
237
|
-
document.getElementById("menuFeatureOptions").addEventListener("click", () => this
|
|
269
|
+
document.getElementById("menuFeatureOptions").addEventListener("click", () => this.#showFeatureOptions());
|
|
238
270
|
document.getElementById("menuSettings").addEventListener("click", () => this.#showSettings());
|
|
239
271
|
|
|
240
272
|
// 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
|
package/dist/util.d.ts
CHANGED
|
@@ -8,8 +8,8 @@ import type { Logging } from "homebridge";
|
|
|
8
8
|
* The canonical set of abort reasons used across `homebridge-plugin-utils`.
|
|
9
9
|
*
|
|
10
10
|
* Every long-lived resource class in the library exposes an {@link AbortSignal} whose abort reason is normally an {@link HbpuAbortError} carrying one of these names.
|
|
11
|
-
* Consumers
|
|
12
|
-
* `TimeoutError` and `AbortError` from the platform both flow through the same
|
|
11
|
+
* Consumers branch on the `.name` field. Platform errors produced by `AbortSignal.timeout()` and bare `controller.abort()` interoperate by matching names:
|
|
12
|
+
* `TimeoutError` and `AbortError` from the platform both flow through the same branching paths unchanged.
|
|
13
13
|
*
|
|
14
14
|
* @remarks When to use each reason:
|
|
15
15
|
*
|
|
@@ -34,7 +34,7 @@ export interface HbpuAbortErrorOptions {
|
|
|
34
34
|
*/
|
|
35
35
|
cause?: unknown;
|
|
36
36
|
/**
|
|
37
|
-
* Optional human-readable message. When omitted, the error's `message` defaults to the reason name, which is sufficient for
|
|
37
|
+
* Optional human-readable message. When omitted, the error's `message` defaults to the reason name, which is sufficient for name-based handling.
|
|
38
38
|
*/
|
|
39
39
|
message?: string;
|
|
40
40
|
}
|
|
@@ -73,7 +73,7 @@ export interface HbpuAbortErrorOptions {
|
|
|
73
73
|
*/
|
|
74
74
|
export declare class HbpuAbortError extends Error {
|
|
75
75
|
/**
|
|
76
|
-
* The
|
|
76
|
+
* The tag. Matches one of {@link HbpuAbortReason}.
|
|
77
77
|
*/
|
|
78
78
|
readonly name: HbpuAbortReason;
|
|
79
79
|
/**
|
|
@@ -87,7 +87,7 @@ export declare class HbpuAbortError extends Error {
|
|
|
87
87
|
/**
|
|
88
88
|
* Type guard: returns `true` if `error` is an {@link HbpuAbortError}.
|
|
89
89
|
*
|
|
90
|
-
* Use this to
|
|
90
|
+
* Use this to distinguish HBPU's canonical abort errors from arbitrary thrown values, without nesting `instanceof` checks.
|
|
91
91
|
*
|
|
92
92
|
* @param error - The value to test.
|
|
93
93
|
*
|
|
@@ -101,7 +101,7 @@ export declare function isHbpuAbortError(error: unknown): error is HbpuAbortErro
|
|
|
101
101
|
* `error.cause` and related fields without further casts.
|
|
102
102
|
*
|
|
103
103
|
* Collapses the common "is this an HBPU abort, and was it this specific reason?" question into a single call, avoiding the `instanceof` + `.name` nesting that appears
|
|
104
|
-
* throughout consuming code. The generic parameter `R` preserves the specific reason string in the narrowed type so callers that
|
|
104
|
+
* throughout consuming code. The generic parameter `R` preserves the specific reason string in the narrowed type so callers that distinguish further by name get the
|
|
105
105
|
* literal narrowed form automatically.
|
|
106
106
|
*
|
|
107
107
|
* @typeParam R - The specific reason being matched. Defaulted by inference from `reason`.
|
|
@@ -118,7 +118,7 @@ export declare function isHbpuAbortReason<R extends HbpuAbortReason>(error: unkn
|
|
|
118
118
|
/**
|
|
119
119
|
* Test whether an abort reason indicates a timeout. Matches both the canonical {@link HbpuAbortError} with `"timeout"` name - produced by project watchdogs
|
|
120
120
|
* ({@link Watchdog}, the inactivity monitors on `FfmpegProcess` / `RtpDemuxer` / `Mp4SegmentAssembler`) - and the platform {@link DOMException}/`Error` whose
|
|
121
|
-
* `.name === "TimeoutError"` - produced by `AbortSignal.timeout()`. Consumers
|
|
121
|
+
* `.name === "TimeoutError"` - produced by `AbortSignal.timeout()`. Consumers branch on a single predicate regardless of which code path originated the timeout.
|
|
122
122
|
*
|
|
123
123
|
* Exists because every long-lived resource class exposes an `isTimedOut` getter with identical branching logic; routing all of them through this single predicate
|
|
124
124
|
* enforces one taxonomy and eliminates drift if the project ever needs to add, say, a third timeout shape (e.g., an upstream-framework cancellation).
|
|
@@ -185,7 +185,7 @@ export declare function isTimeoutReason(reason: unknown): boolean;
|
|
|
185
185
|
* // Abort-driven action goes here.
|
|
186
186
|
* });
|
|
187
187
|
*
|
|
188
|
-
* // `return await promise` (not a bare `return promise`) is
|
|
188
|
+
* // `return await promise` (not a bare `return promise`) is required inside an async function. `using` disposes when the enclosing function body finishes
|
|
189
189
|
* // executing, and without an `await` the body finishes synchronously at the `return` statement - even though the returned promise is still pending. The
|
|
190
190
|
* // listener would therefore be removed the instant the function returned, well before the promise settles. Adding `await` creates a suspension point that
|
|
191
191
|
* // keeps the `using` scope alive until the promise actually settles, which is what the "scope-bound registration" pattern relies on.
|
|
@@ -590,9 +590,9 @@ export declare function composeSignals(...signals: (AbortSignal | undefined)[]):
|
|
|
590
590
|
* Supervise a detached, signal-bound async loop: run the loop, resolve quietly when it ends or its signal aborts, and route any genuine fault to a caller-supplied
|
|
591
591
|
* handler exactly once.
|
|
592
592
|
*
|
|
593
|
-
* Resilient background loops - membership observers, reachability probes, telemetry firehoses - all share one subtle, correctness-critical
|
|
593
|
+
* Resilient background loops - membership observers, reachability probes, telemetry firehoses - all share one subtle, correctness-critical rule: a throw is a
|
|
594
594
|
* *fault* only when we did not cause it. When the bound signal is aborted, a throw is the orderly unwinding of a loop the caller already tore down, so it is swallowed
|
|
595
|
-
* silently. Any other throw is a genuine fault and is handed to `onError` exactly once. Hand-copying that swallow-on-abort-versus-surface-once
|
|
595
|
+
* silently. Any other throw is a genuine fault and is handed to `onError` exactly once. Hand-copying that swallow-on-abort-versus-surface-once distinction across
|
|
596
596
|
* call sites that share no ancestor is how it drifts apart; owning it in one generic primitive is how it stays consistent.
|
|
597
597
|
*
|
|
598
598
|
* The home is here, beside {@link composeSignals}, because the envelope is fully generic - it carries no logging policy, no message wording, and makes no detachment
|
|
@@ -674,6 +674,83 @@ export declare function superviseLoop(options: {
|
|
|
674
674
|
* @category Utilities
|
|
675
675
|
*/
|
|
676
676
|
export declare function loopFaultReporter(log: HomebridgePluginLogging, label: string): (error: unknown) => void;
|
|
677
|
+
/**
|
|
678
|
+
* The minimal completion-callback shape {@link guardedDispatch} guards. HomeKit's camera-delegate callbacks (snapshot, prepare-stream, stream-request) each answer with
|
|
679
|
+
* an optional leading `Error` and, in some cases, an optional trailing payload; every one is structurally assignable to this error-first shape, so `guardedDispatch`
|
|
680
|
+
* serves them all without importing any HomeKit or hap-nodejs type. A richer callback that also carries a success payload is preserved through the generic parameter on
|
|
681
|
+
* {@link guardedDispatch}, which keeps the caller's exact signature while still guarding it.
|
|
682
|
+
*
|
|
683
|
+
* @category Utilities
|
|
684
|
+
*/
|
|
685
|
+
export type DispatchCallback = (error?: Error) => void;
|
|
686
|
+
/**
|
|
687
|
+
* Run an async handler that HomeKit invokes without awaiting - a camera-delegate method whose interface return type is `void` - so a rejection can never float as an
|
|
688
|
+
* unhandled rejection and the delegate's completion callback is answered exactly once.
|
|
689
|
+
*
|
|
690
|
+
* HomeKit's camera-delegate methods (snapshot, prepare-stream, stream-request) are declared to return `void` yet are naturally written as `async`. Calling an async
|
|
691
|
+
* method in that position discards its promise: a rejection surfaces as a process-level unhandled rejection, and if the method faulted before answering its callback,
|
|
692
|
+
* HomeKit waits forever for a response that never comes. `guardedDispatch` closes both gaps. It owns a once-guard around the real callback and hands the guarded callback
|
|
693
|
+
* to `handler`, so however the handler behaves - answered then faulted, faulted before answering, or answered twice by mistake - the real callback fires exactly once:
|
|
694
|
+
* the first answer wins; a fault after an answer is logged and the earlier answer stands; a fault before any answer is delivered to HomeKit through the callback itself.
|
|
695
|
+
* The handler's promise is marked observed through {@link markHandled}, so nothing floats.
|
|
696
|
+
*
|
|
697
|
+
* @typeParam C - The caller's exact callback signature. Constrained to {@link DispatchCallback} (error-first) so any HomeKit delegate callback fits, while
|
|
698
|
+
* preserving a richer signature - one that also passes a snapshot buffer or stream response - for the handler to answer with.
|
|
699
|
+
* @param options - Dispatch inputs.
|
|
700
|
+
* @param options.callback - The real completion callback HomeKit supplied. It is wrapped in a once-guard and never invoked more than once.
|
|
701
|
+
* @param options.handler - The async work, given the guarded callback to answer with. A rejection, or a synchronous throw, is caught: while the callback is still open
|
|
702
|
+
* it receives the error, otherwise the error is logged.
|
|
703
|
+
* @param options.label - A short human-readable name for the operation (for example `"snapshot request"`), interpolated into the failure log line.
|
|
704
|
+
* @param options.log - The logger a post-answer fault is reported through.
|
|
705
|
+
*
|
|
706
|
+
* @example
|
|
707
|
+
*
|
|
708
|
+
* ```ts
|
|
709
|
+
* import { guardedDispatch } from "homebridge-plugin-utils";
|
|
710
|
+
*
|
|
711
|
+
* // A snapshot delegate HomeKit calls without awaiting. The method itself returns void; the async work and its one-shot callback are handed to guardedDispatch.
|
|
712
|
+
* public handleSnapshotRequest(request: SnapshotRequest, callback: SnapshotRequestCallback): void {
|
|
713
|
+
*
|
|
714
|
+
* guardedDispatch({ callback, handler: (answer) => this.snapshot(request, answer), label: "snapshot request", log: this.log });
|
|
715
|
+
* }
|
|
716
|
+
* ```
|
|
717
|
+
*
|
|
718
|
+
* @category Utilities
|
|
719
|
+
*/
|
|
720
|
+
export declare function guardedDispatch<C extends DispatchCallback>(options: {
|
|
721
|
+
callback: C;
|
|
722
|
+
handler: (callback: C) => Promise<void>;
|
|
723
|
+
label: string;
|
|
724
|
+
log: Logger;
|
|
725
|
+
}): void;
|
|
726
|
+
/**
|
|
727
|
+
* Run an async, callback-less handler that HomeKit (or any caller) invokes without awaiting, so a rejection can never float as an unhandled rejection. With no callback
|
|
728
|
+
* to carry a failure back, a fault is simply logged.
|
|
729
|
+
*
|
|
730
|
+
* @param options - Dispatch inputs.
|
|
731
|
+
* @param options.handler - The async work to run. A rejection, or a synchronous throw, is caught and logged.
|
|
732
|
+
* @param options.label - A short human-readable name for the operation (for example `"recording activation"`), interpolated into the failure log line.
|
|
733
|
+
* @param options.log - The logger a fault is reported through.
|
|
734
|
+
*
|
|
735
|
+
* @example
|
|
736
|
+
*
|
|
737
|
+
* ```ts
|
|
738
|
+
* import { guardedDispatch } from "homebridge-plugin-utils";
|
|
739
|
+
*
|
|
740
|
+
* // A recording-state update HomeKit calls without awaiting. There is no callback, so a fault is logged rather than reported back to HomeKit.
|
|
741
|
+
* public updateRecordingActive(active: boolean): void {
|
|
742
|
+
*
|
|
743
|
+
* guardedDispatch({ handler: () => this.applyRecordingActive(active), label: "recording activation", log: this.log });
|
|
744
|
+
* }
|
|
745
|
+
* ```
|
|
746
|
+
*
|
|
747
|
+
* @category Utilities
|
|
748
|
+
*/
|
|
749
|
+
export declare function guardedDispatch(options: {
|
|
750
|
+
handler: () => Promise<void>;
|
|
751
|
+
label: string;
|
|
752
|
+
log: Logger;
|
|
753
|
+
}): void;
|
|
677
754
|
/**
|
|
678
755
|
* Options for {@link runWithAbort}. At least one of `signal` or `timeout` must be provided so there is always an abort mechanism. TypeScript enforces this at compile
|
|
679
756
|
* time through a discriminated union - the "no abort mechanism" case is unrepresentable.
|
|
@@ -790,12 +867,12 @@ export declare class Watchdog implements Disposable {
|
|
|
790
867
|
arm(): void;
|
|
791
868
|
/**
|
|
792
869
|
* Cancel any pending fire without aborting anything and without marking the watchdog as permanently dead. Subsequent `arm()` calls continue to work. Safe to call
|
|
793
|
-
* when no arm is pending -
|
|
870
|
+
* when no arm is pending - repeat calls are no-ops.
|
|
794
871
|
*/
|
|
795
872
|
clear(): void;
|
|
796
873
|
/**
|
|
797
874
|
* `Disposable` implementation. Clears any pending fire AND permanently disables the watchdog: after this runs, `arm()` is a no-op and no further `onFire` calls can
|
|
798
|
-
* occur. This is the contract `using watchdog = new Watchdog(...)` relies on - the resource is dead when the block exits, not merely quiescent.
|
|
875
|
+
* occur. This is the contract `using watchdog = new Watchdog(...)` relies on - the resource is dead when the block exits, not merely quiescent. Repeated
|
|
799
876
|
* disposal is a no-op. Because the class does not own an abort controller, disposal does not signal anything to the rest of the system.
|
|
800
877
|
*/
|
|
801
878
|
[Symbol.dispose](): void;
|