homebridge-plugin-utils 1.35.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.
Files changed (204) hide show
  1. package/README.md +130 -2
  2. package/build/eslint-plugin/README.md +164 -0
  3. package/build/eslint-plugin/config.mjs +308 -0
  4. package/build/eslint-plugin/index.mjs +7 -0
  5. package/build/eslint-plugin/plugin.mjs +46 -0
  6. package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
  7. package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
  8. package/build/eslint-plugin/rules/comment-style.mjs +190 -0
  9. package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
  10. package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
  11. package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
  12. package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
  13. package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
  14. package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
  15. package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
  16. package/build/eslint-plugin/test-setup.mjs +21 -0
  17. package/build/fs-ops.mjs +184 -0
  18. package/build/tsconfig.json +18 -3
  19. package/dist/backpressure.d.ts +94 -45
  20. package/dist/backpressure.js +229 -93
  21. package/dist/backpressure.js.map +1 -1
  22. package/dist/cli/index.d.ts +166 -0
  23. package/dist/cli/index.js +551 -0
  24. package/dist/cli/index.js.map +1 -0
  25. package/dist/clock-double.d.ts +93 -0
  26. package/dist/clock-double.js +141 -0
  27. package/dist/clock-double.js.map +1 -0
  28. package/dist/clock.d.ts +39 -0
  29. package/dist/clock.js +34 -0
  30. package/dist/clock.js.map +1 -0
  31. package/dist/disposable-stack.d.ts +59 -0
  32. package/dist/disposable-stack.js +155 -0
  33. package/dist/disposable-stack.js.map +1 -0
  34. package/dist/docChrome.d.ts +260 -0
  35. package/dist/docChrome.js +361 -0
  36. package/dist/docChrome.js.map +1 -0
  37. package/dist/eslint-plugin/config.d.mts +193 -0
  38. package/dist/eslint-plugin/index.d.mts +2 -0
  39. package/dist/eslint-plugin/plugin.d.mts +87 -0
  40. package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
  41. package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
  42. package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
  43. package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
  44. package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
  45. package/dist/featureOptions-docs.d.ts +96 -0
  46. package/dist/featureOptions-docs.js +193 -0
  47. package/dist/featureOptions-docs.js.map +1 -0
  48. package/dist/featureOptions.d.ts +674 -0
  49. package/dist/featureOptions.js +870 -0
  50. package/dist/featureOptions.js.map +1 -0
  51. package/dist/ffmpeg/codecs.d.ts +256 -72
  52. package/dist/ffmpeg/codecs.js +477 -262
  53. package/dist/ffmpeg/codecs.js.map +1 -1
  54. package/dist/ffmpeg/dgram-util.d.ts +46 -0
  55. package/dist/ffmpeg/dgram-util.js +38 -0
  56. package/dist/ffmpeg/dgram-util.js.map +1 -0
  57. package/dist/ffmpeg/exec.d.ts +83 -64
  58. package/dist/ffmpeg/exec.js +77 -86
  59. package/dist/ffmpeg/exec.js.map +1 -1
  60. package/dist/ffmpeg/fmp4.d.ts +49 -2
  61. package/dist/ffmpeg/fmp4.js +47 -11
  62. package/dist/ffmpeg/fmp4.js.map +1 -1
  63. package/dist/ffmpeg/hap-enums.d.ts +214 -0
  64. package/dist/ffmpeg/hap-enums.js +92 -0
  65. package/dist/ffmpeg/hap-enums.js.map +1 -0
  66. package/dist/ffmpeg/index.d.ts +16 -9
  67. package/dist/ffmpeg/index.js +6 -0
  68. package/dist/ffmpeg/index.js.map +1 -1
  69. package/dist/ffmpeg/mp4-assembler.d.ts +161 -0
  70. package/dist/ffmpeg/mp4-assembler.js +424 -0
  71. package/dist/ffmpeg/mp4-assembler.js.map +1 -0
  72. package/dist/ffmpeg/mp4-parser.d.ts +94 -0
  73. package/dist/ffmpeg/mp4-parser.js +130 -0
  74. package/dist/ffmpeg/mp4-parser.js.map +1 -0
  75. package/dist/ffmpeg/options.d.ts +62 -149
  76. package/dist/ffmpeg/options.js +608 -499
  77. package/dist/ffmpeg/options.js.map +1 -1
  78. package/dist/ffmpeg/process.d.ts +142 -96
  79. package/dist/ffmpeg/process.js +406 -278
  80. package/dist/ffmpeg/process.js.map +1 -1
  81. package/dist/ffmpeg/record.d.ts +325 -186
  82. package/dist/ffmpeg/record.js +418 -565
  83. package/dist/ffmpeg/record.js.map +1 -1
  84. package/dist/ffmpeg/recording-process-double.d.ts +157 -0
  85. package/dist/ffmpeg/recording-process-double.js +190 -0
  86. package/dist/ffmpeg/recording-process-double.js.map +1 -0
  87. package/dist/ffmpeg/rtp-parser.d.ts +70 -0
  88. package/dist/ffmpeg/rtp-parser.js +77 -0
  89. package/dist/ffmpeg/rtp-parser.js.map +1 -0
  90. package/dist/ffmpeg/rtp.d.ts +198 -141
  91. package/dist/ffmpeg/rtp.js +474 -251
  92. package/dist/ffmpeg/rtp.js.map +1 -1
  93. package/dist/ffmpeg/settings.d.ts +5 -2
  94. package/dist/ffmpeg/settings.js +20 -5
  95. package/dist/ffmpeg/settings.js.map +1 -1
  96. package/dist/ffmpeg/stream.d.ts +57 -107
  97. package/dist/ffmpeg/stream.js +121 -150
  98. package/dist/ffmpeg/stream.js.map +1 -1
  99. package/dist/formatters.d.ts +106 -0
  100. package/dist/formatters.js +174 -0
  101. package/dist/formatters.js.map +1 -0
  102. package/dist/homebridge-enums.d.ts +30 -0
  103. package/dist/homebridge-enums.js +17 -0
  104. package/dist/homebridge-enums.js.map +1 -0
  105. package/dist/index.d.ts +13 -6
  106. package/dist/index.js +8 -2
  107. package/dist/index.js.map +1 -1
  108. package/dist/logclient/auth.d.ts +114 -0
  109. package/dist/logclient/auth.js +199 -0
  110. package/dist/logclient/auth.js.map +1 -0
  111. package/dist/logclient/cli-run.d.ts +76 -0
  112. package/dist/logclient/cli-run.js +639 -0
  113. package/dist/logclient/cli-run.js.map +1 -0
  114. package/dist/logclient/cli.d.ts +3 -0
  115. package/dist/logclient/cli.js +97 -0
  116. package/dist/logclient/cli.js.map +1 -0
  117. package/dist/logclient/client.d.ts +145 -0
  118. package/dist/logclient/client.js +600 -0
  119. package/dist/logclient/client.js.map +1 -0
  120. package/dist/logclient/config.d.ts +173 -0
  121. package/dist/logclient/config.js +199 -0
  122. package/dist/logclient/config.js.map +1 -0
  123. package/dist/logclient/endpoints.d.ts +54 -0
  124. package/dist/logclient/endpoints.js +73 -0
  125. package/dist/logclient/endpoints.js.map +1 -0
  126. package/dist/logclient/filter.d.ts +45 -0
  127. package/dist/logclient/filter.js +51 -0
  128. package/dist/logclient/filter.js.map +1 -0
  129. package/dist/logclient/frame.d.ts +93 -0
  130. package/dist/logclient/frame.js +203 -0
  131. package/dist/logclient/frame.js.map +1 -0
  132. package/dist/logclient/index.d.ts +31 -0
  133. package/dist/logclient/index.js +12 -0
  134. package/dist/logclient/index.js.map +1 -0
  135. package/dist/logclient/parser.d.ts +211 -0
  136. package/dist/logclient/parser.js +393 -0
  137. package/dist/logclient/parser.js.map +1 -0
  138. package/dist/logclient/rest.d.ts +41 -0
  139. package/dist/logclient/rest.js +111 -0
  140. package/dist/logclient/rest.js.map +1 -0
  141. package/dist/logclient/settings.d.ts +15 -0
  142. package/dist/logclient/settings.js +64 -0
  143. package/dist/logclient/settings.js.map +1 -0
  144. package/dist/logclient/socket-double.d.ts +201 -0
  145. package/dist/logclient/socket-double.js +384 -0
  146. package/dist/logclient/socket-double.js.map +1 -0
  147. package/dist/logclient/socket.d.ts +257 -0
  148. package/dist/logclient/socket.js +620 -0
  149. package/dist/logclient/socket.js.map +1 -0
  150. package/dist/logclient/stitch.d.ts +83 -0
  151. package/dist/logclient/stitch.js +146 -0
  152. package/dist/logclient/stitch.js.map +1 -0
  153. package/dist/logclient/time-expression.d.ts +42 -0
  154. package/dist/logclient/time-expression.js +181 -0
  155. package/dist/logclient/time-expression.js.map +1 -0
  156. package/dist/logclient/time-window.d.ts +38 -0
  157. package/dist/logclient/time-window.js +53 -0
  158. package/dist/logclient/time-window.js.map +1 -0
  159. package/dist/logclient/types.d.ts +107 -0
  160. package/dist/logclient/types.js +6 -0
  161. package/dist/logclient/types.js.map +1 -0
  162. package/dist/mqttClient.d.ts +287 -0
  163. package/dist/mqttClient.js +433 -0
  164. package/dist/mqttClient.js.map +1 -0
  165. package/dist/service.d.ts +64 -15
  166. package/dist/service.js +93 -66
  167. package/dist/service.js.map +1 -1
  168. package/dist/ui/featureOptions.js +870 -0
  169. package/dist/ui/featureOptions.js.map +1 -0
  170. package/dist/ui/formatters.js +174 -0
  171. package/dist/ui/formatters.js.map +1 -0
  172. package/dist/ui/pluginConfigSession.mjs +141 -0
  173. package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
  174. package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
  175. package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
  176. package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
  177. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
  178. package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
  179. package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
  180. package/dist/ui/webUi-featureOptions/state.mjs +319 -0
  181. package/dist/ui/webUi-featureOptions/store.mjs +181 -0
  182. package/dist/ui/webUi-featureOptions/utils.mjs +213 -0
  183. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
  184. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
  185. package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
  186. package/dist/ui/webUi-featureOptions/views/nav.mjs +341 -0
  187. package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
  188. package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
  189. package/dist/ui/webUi-featureOptions.mjs +702 -0
  190. package/dist/ui/webUi.mjs +225 -88
  191. package/dist/util.d.ts +679 -45
  192. package/dist/util.js +830 -77
  193. package/dist/util.js.map +1 -1
  194. package/package.json +33 -15
  195. package/build/eslint-rules.mjs +0 -511
  196. package/dist/featureoptions.d.ts +0 -264
  197. package/dist/featureoptions.js +0 -480
  198. package/dist/featureoptions.js.map +0 -1
  199. package/dist/mqttclient.d.ts +0 -178
  200. package/dist/mqttclient.js +0 -310
  201. package/dist/mqttclient.js.map +0 -1
  202. package/dist/ui/featureoptions.js +0 -480
  203. package/dist/ui/featureoptions.js.map +0 -1
  204. package/dist/ui/webUi-featureoptions.mjs +0 -3765
package/dist/ui/webUi.mjs CHANGED
@@ -4,181 +4,318 @@
4
4
  */
5
5
  "use strict";
6
6
 
7
- import { webUiFeatureOptions } from "./webUi-featureoptions.mjs";
7
+ import { PluginConfigSession } from "./pluginConfigSession.mjs";
8
+ import { toastError } from "./webUi-featureOptions/utils.mjs";
9
+ import { webUiFeatureOptions } from "./webUi-featureOptions.mjs";
10
+
11
+ /**
12
+ * @typedef {Object} FirstRunContext
13
+ * @property {(patch: Object) => Promise<void>} [commit] - Persist a patch to the primary platform-config entry. Supplied only to `onSubmit` (the one write hook).
14
+ * @property {Object} config - The primary platform-config entry, injected so the hook is a pure function of its input rather than reaching for the config itself.
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} FirstRunHandlers
19
+ * @property {(context: FirstRunContext) => boolean | Promise<boolean>} [isRequired] - Returns truthy when the first-run flow must run before the main UI is shown.
20
+ * @property {(context: FirstRunContext) => boolean | Promise<boolean>} [onStart] - Initialization for the first-run UI; populates forms and runs any startup tasks.
21
+ * @property {(context: FirstRunContext) => boolean | Promise<boolean>} [onSubmit] - Executes the first-run workflow, typically a login or configuration validation.
22
+ */
8
23
 
24
+ /**
25
+ * @typedef {Object} WebUiConfig
26
+ * @property {Object} [featureOptions] - Parameters forwarded to {@link webUiFeatureOptions}.
27
+ * @property {FirstRunHandlers} [firstRun] - First-run lifecycle hooks.
28
+ * @property {string} [name] - Plugin name used to seed a fresh configuration.
29
+ */
30
+
31
+ /**
32
+ * webUi - Top-level plugin webUI orchestrator.
33
+ *
34
+ * Owns the page-level menu state, the first-run flow, and the {@link webUiFeatureOptions} instance that renders the feature options page. The orchestrator is the
35
+ * single entry point Homebridge invokes to render the configuration UI; everything else - feature option discovery, theming, sidebar navigation, search - lives in
36
+ * the composed {@link webUiFeatureOptions} instance and its sub-components.
37
+ */
9
38
  export class webUi {
10
39
 
11
- // Feature options class instance.
12
40
  featureOptions;
13
41
 
14
- // First run webUI callback endpoints for customization.
15
42
  #firstRun;
16
-
17
- // Plugin name.
18
43
  #name;
44
+ #session;
19
45
 
20
46
  /**
21
- * featureOptions - parameters to webUiFeatureOptions.
22
- * firstRun - first run handlers:
23
- * isRequired - do we need to run the first run UI workflow?
24
- * onStart - initialization for the first run webUI to populate forms and other startup tasks.
25
- * onSubmit - execute the first run workflow, typically a login or configuration validation of some sort.
26
- * name - plugin name.
47
+ * Initialize the plugin webUI orchestrator.
48
+ *
49
+ * Constructs the composed {@link webUiFeatureOptions} instance immediately so the feature-options page is ready to render the moment the user navigates to it.
50
+ * Caller-supplied first-run hooks are merged in a single spread over the default no-op handlers, so partial overrides work naturally - a caller can supply only
51
+ * `onSubmit` and the unspecified slots stay at the defaults that keep the flow driveable.
52
+ *
53
+ * @param {WebUiConfig} [options] - Configuration options for the webUI. All fields are optional; firstRun's hooks fall back to no-op handlers, and
54
+ * featureOptions/name simply default to undefined.
27
55
  */
28
56
  constructor({ featureOptions, firstRun = {}, name } = {}) {
29
57
 
30
- // Defaults for our first run handlers.
31
- this.#firstRun = { isRequired: () => false, onStart: () => true, onSubmit: () => true };
58
+ // First-run handlers default to no-ops; caller-supplied entries override per-key. The single-statement spread lands `#firstRun` in its final shape on first
59
+ // assignment, so there is no intermediate object that gets discarded a line later.
60
+ this.#firstRun = { isRequired: () => false, onStart: () => true, onSubmit: () => true, ...firstRun };
32
61
 
33
- // Figure out the options passed in to us.
34
62
  this.featureOptions = new webUiFeatureOptions(featureOptions);
35
- this.#firstRun = Object.assign({}, this.#firstRun, firstRun);
36
63
  this.#name = name;
37
64
  }
38
65
 
39
66
  /**
40
67
  * Render the webUI.
68
+ *
69
+ * Public entry point Homebridge invokes when the configuration UI is opened. Delegates the actual rendering to {@link #launchWebUI}; this wrapper exists to
70
+ * standardize error handling (a launch failure becomes a user-facing toast rather than a silent broken UI) and to guarantee the spinner is hidden no matter how
71
+ * the launch settles. The `finally` runs after the awaited launch resolves or rejects, so the spinner stays visible for the full duration of the async setup
72
+ * rather than disappearing the moment the synchronous portion of the call returns.
73
+ *
74
+ * @returns {Promise<void>}
75
+ * @public
41
76
  */
42
- // Render the UI.
43
- show() {
77
+ async show() {
44
78
 
45
- // Fire off our UI, catching errors along the way.
46
79
  try {
47
80
 
48
- this.#launchWebUI();
81
+ await this.#launchWebUI();
49
82
  } catch(err) {
50
83
 
51
- // If we had an error instantiating or updating the UI, notify the user.
52
- homebridge.toast.error(err.message, "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);
53
87
  } finally {
54
88
 
55
- // Always leave the UI in a usable place for the end user.
56
89
  homebridge.hideSpinner();
57
90
  }
58
91
  }
59
92
 
60
- // Show the first run user experience if needed.
93
+ /**
94
+ * Show the first-run user experience.
95
+ *
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. 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.
101
+ *
102
+ * @returns {Promise<void>}
103
+ * @private
104
+ */
61
105
  async #showFirstRun() {
62
106
 
63
107
  const buttonFirstRun = document.getElementById("firstRun");
64
108
 
65
- // Run a custom initialization handler the user may have provided.
66
- if(!(await this.#processHandler(this.#firstRun.onStart))) {
109
+ // Inject the primary platform-config entry so the hook reads its config from its argument rather than reaching for the session or the host. onStart only reads
110
+ // (it pre-populates the form), so it receives config without the writer.
111
+ if(!(await this.#processHandler(this.#firstRun.onStart, { config: this.#session.platform }))) {
67
112
 
68
113
  return;
69
114
  }
70
115
 
71
- // We disable saving any settings until we configure the plugin.
72
116
  homebridge.disableSaveButton();
73
117
 
74
- // First run user experience.
75
118
  buttonFirstRun.addEventListener("click", async () => {
76
119
 
77
- // Show the beachball while we setup.
78
120
  homebridge.showSpinner();
79
121
 
80
- // Run a custom submit handler the user may have provided.
81
- if(!(await this.#processHandler(this.#firstRun.onSubmit))) {
122
+ try {
82
123
 
83
- return;
84
- }
124
+ // onSubmit is the one write hook: it validates credentials and persists them. It receives both the current config and a `commit` bound to the session's
125
+ // single write seam, so the hook owns the shape of the write (it knows credentials live under the controllers array) while the session owns persistence.
126
+ if(!(await this.#processHandler(this.#firstRun.onSubmit, { commit: (patch) => this.#session.commit(patch), config: this.#session.platform }))) {
85
127
 
86
- // Create our UI and allow users to save the configuration.
87
- document.getElementById("pageFirstRun").style.display = "none";
88
- document.getElementById("menuWrapper").style.display = "inline-flex";
128
+ return;
129
+ }
130
+
131
+ // Swap from the first-run page to the main configuration UI and hand off to the feature-options view. The feature-options surface manages its own
132
+ // progressive disclosure - page-shell visible immediately, regions populating as their I/O resolves - so the click handler's spinner is the only one that
133
+ // brackets this transition. The `try/finally` ensures it comes down on every exit path, including the early bail above.
134
+ document.getElementById("pageFirstRun").style.display = "none";
135
+ document.getElementById("menuWrapper").style.display = "inline-flex";
89
136
 
90
- await this.featureOptions.show();
137
+ await this.featureOptions.show(this.#session);
91
138
 
92
- homebridge.enableSaveButton();
139
+ homebridge.enableSaveButton();
140
+ } catch(err) {
93
141
 
94
- // All done. Let the user interact with us, although in practice, we shouldn't get here.
95
- // homebridge.hideSpinner();
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);
147
+ } finally {
148
+
149
+ homebridge.hideSpinner();
150
+ }
96
151
  });
97
152
 
98
153
  document.getElementById("pageFirstRun").style.display = "block";
99
154
  }
100
155
 
101
- // Show the main plugin configuration tab.
102
- #showSettings() {
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
+
177
+ /**
178
+ * Show the main plugin configuration tab.
179
+ *
180
+ * Hides the feature-options view, swaps the menu button states (home and feature-options become primary; settings becomes elegant to indicate the active tab),
181
+ * and asks Homebridge to render its built-in schema-driven settings form. The spinner brackets the swap so transient layout shifts are not visible to the user.
182
+ *
183
+ * Awaits `featureOptions.hide()` BEFORE revealing the schema form so any debounced-but-unwritten option edit is flushed into Homebridge's in-memory config model
184
+ * first - the Settings form then renders against the flushed config rather than a stale snapshot. The try/finally guarantees the spinner comes down and the tab
185
+ * reveals even if the drain rejects (the drain's own failure path already toasts via `persist:failed`), so a persistence error never strands the user on a spinner.
186
+ *
187
+ * @returns {Promise<void>}
188
+ * @private
189
+ */
190
+ async #showSettings() {
103
191
 
104
- // Show the beachball while we setup.
105
192
  homebridge.showSpinner();
106
- this.featureOptions.hide();
107
193
 
108
- // Highlight the tab in our UI.
109
- this.#toggleClasses("menuHome", "btn-elegant", "btn-primary");
110
- this.#toggleClasses("menuFeatureOptions", "btn-elegant", "btn-primary");
111
- this.#toggleClasses("menuSettings", "btn-primary", "btn-elegant");
194
+ try {
195
+
196
+ await this.featureOptions.hide();
197
+ } finally {
112
198
 
113
- document.getElementById("pageSupport").style.display = "none";
114
- document.getElementById("pageFeatureOptions").style.display = "none";
199
+ this.#toggleClasses("menuHome", "btn-elegant", "btn-primary");
200
+ this.#toggleClasses("menuFeatureOptions", "btn-elegant", "btn-primary");
201
+ this.#toggleClasses("menuSettings", "btn-primary", "btn-elegant");
115
202
 
116
- homebridge.showSchemaForm();
203
+ document.getElementById("pageSupport").style.display = "none";
204
+ document.getElementById("pageFeatureOptions").style.display = "none";
117
205
 
118
- // All done. Let the user interact with us.
119
- homebridge.hideSpinner();
206
+ homebridge.showSchemaForm();
207
+
208
+ homebridge.hideSpinner();
209
+ }
120
210
  }
121
211
 
122
- // Show the support tab.
123
- #showSupport() {
212
+ /**
213
+ * Show the support tab.
214
+ *
215
+ * Hides the feature-options view and the schema form, swaps the menu button states (home becomes elegant as the active tab; feature-options and settings revert
216
+ * to primary), and reveals the static support page. Spinner brackets the swap to mask transient layout shifts.
217
+ *
218
+ * Awaits `featureOptions.hide()` BEFORE revealing the support page so any debounced-but-unwritten option edit is flushed first, matching the Settings path. The
219
+ * try/finally guarantees the spinner comes down and the tab reveals even if the drain rejects (the drain's own failure path already toasts via `persist:failed`).
220
+ *
221
+ * @returns {Promise<void>}
222
+ * @private
223
+ */
224
+ async #showSupport() {
124
225
 
125
- // Show the beachball while we setup.
126
226
  homebridge.showSpinner();
127
227
  homebridge.hideSchemaForm();
128
- this.featureOptions.hide();
129
228
 
130
- // Highlight the tab in our UI.
131
- this.#toggleClasses("menuHome", "btn-primary", "btn-elegant");
132
- this.#toggleClasses("menuFeatureOptions", "btn-elegant", "btn-primary");
133
- this.#toggleClasses("menuSettings", "btn-elegant", "btn-primary");
229
+ try {
230
+
231
+ await this.featureOptions.hide();
232
+ } finally {
233
+
234
+ this.#toggleClasses("menuHome", "btn-primary", "btn-elegant");
235
+ this.#toggleClasses("menuFeatureOptions", "btn-elegant", "btn-primary");
236
+ this.#toggleClasses("menuSettings", "btn-elegant", "btn-primary");
134
237
 
135
- document.getElementById("pageSupport").style.display = "block";
136
- document.getElementById("pageFeatureOptions").style.display = "none";
238
+ document.getElementById("pageSupport").style.display = "block";
239
+ document.getElementById("pageFeatureOptions").style.display = "none";
137
240
 
138
- // All done. Let the user interact with us.
139
- homebridge.hideSpinner();
241
+ homebridge.hideSpinner();
242
+ }
140
243
  }
141
244
 
142
- // Launch our webUI.
245
+ /**
246
+ * Launch the webUI.
247
+ *
248
+ * Opens the configuration session, wires the menu event listeners, and routes the user to either the feature-options view (when the caller's first-run gate says
249
+ * no) or the first-run flow (when it says yes). The session loads the host config once and seeds the minimum shape, so routing and every downstream reader share
250
+ * one config owner rather than fetching it independently.
251
+ *
252
+ * @returns {Promise<void>}
253
+ * @private
254
+ */
143
255
  async #launchWebUI() {
144
256
 
145
- // Retrieve the current plugin configuration.
146
- this.featureOptions.currentConfig = await homebridge.getPluginConfig();
147
-
148
- // Add our event listeners to animate the UI.
257
+ // Open the configuration session: one host read, seeded to the minimum shape. Routing, the first-run flow, and the feature-options page all read their config
258
+ // from this single owner rather than re-fetching it independently - so the routing decision lands before any UI work begins and against the same data every
259
+ // later reader sees.
260
+ this.#session = await PluginConfigSession.open({ host: homebridge, name: this.#name });
261
+
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).
149
268
  document.getElementById("menuHome").addEventListener("click", () => this.#showSupport());
150
- document.getElementById("menuFeatureOptions").addEventListener("click", async () => await this.featureOptions.show());
269
+ document.getElementById("menuFeatureOptions").addEventListener("click", () => this.#showFeatureOptions());
151
270
  document.getElementById("menuSettings").addEventListener("click", () => this.#showSettings());
152
271
 
153
- // If we've got devices detected, we launch our feature option UI. Otherwise, we launch our first run UI.
154
- if(this.featureOptions.currentConfig.length && !(await this.#processHandler(this.#firstRun.isRequired))) {
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
273
+ // flow returns true on a fresh config (no valid credentials yet), and a plugin without one keeps the default `() => false` gate and lands straight on feature
274
+ // options - the right destination for a device-discovery plugin even on a brand-new install. The session has already seeded the minimum shape, so the first-run
275
+ // flow can persist credentials on submit without a separate eager write here.
276
+ if(!(await this.#processHandler(this.#firstRun.isRequired, { config: this.#session.platform }))) {
155
277
 
156
278
  document.getElementById("menuWrapper").style.display = "inline-flex";
157
- await this.featureOptions.show();
279
+ await this.featureOptions.show(this.#session);
158
280
 
159
281
  return;
160
282
  }
161
283
 
162
- // If we have the name property set for the plugin configuration yet, let's do so now. If we don't have a configuration, let's initialize it as well.
163
- (this.featureOptions.currentConfig[0] ??= { name: this.#name }).name ??= this.#name;
164
-
165
- // Update the plugin configuration and launch the first run UI.
166
- await homebridge.updatePluginConfig(this.featureOptions.currentConfig);
167
- this.#showFirstRun();
284
+ // Await first-run setup so the spinner-bracketed window in `show()` only closes after the first-run page is fully wired up - the onStart handler has resolved,
285
+ // the save button is disabled, the click listener is registered, and the page is visible. Returning before this would let `show()`'s `finally` hide the spinner
286
+ // while initialization is still in flight, leaving the user looking at a half-rendered first-run UI.
287
+ await this.#showFirstRun();
168
288
  }
169
289
 
170
- // Utility to process user-provided custom handlers that can handle both synchronous and asynchronous handlers.
171
- async #processHandler(handler) {
172
-
173
- if(((typeof handler === "function") && !(await handler())) || ((typeof handler !== "function") && !handler)) {
174
-
175
- return false;
176
- }
290
+ /**
291
+ * Resolve a caller-supplied handler whose shape may be a function or a plain truthy/falsy value.
292
+ *
293
+ * The first-run hooks accept either a function (synchronous or asynchronous - both forms are awaited via the `await handler()` call below) or a literal truthy
294
+ * value (e.g., a caller that always wants the flow to continue can pass `true`). This helper unifies both shapes into a single `Promise<boolean>` answer so the
295
+ * call sites stay flat. The context object is forwarded to the function form so each hook is a pure function of its injected config (and, for `onSubmit`, the
296
+ * write seam) rather than reaching for the session or the host itself.
297
+ *
298
+ * @param {Function|*} handler - Caller-supplied handler. When a function, it is awaited; otherwise it is treated as a truthy/falsy continuation flag.
299
+ * @param {FirstRunContext} [context] - The injected context forwarded to the function form of the handler.
300
+ * @returns {Promise<boolean>} `true` when the workflow should continue, `false` when it should be aborted.
301
+ * @private
302
+ */
303
+ async #processHandler(handler, context) {
177
304
 
178
- return true;
305
+ return Boolean((typeof handler === "function") ? await handler(context) : handler);
179
306
  }
180
307
 
181
- // Utility to toggle our classes.
308
+ /**
309
+ * Swap one Bootstrap button class for another on a DOM element.
310
+ *
311
+ * The menu uses the Bootstrap (Material Design for Bootstrap) `btn-primary` / `btn-elegant` pair to encode active vs inactive tabs. Tab-switch handlers call this
312
+ * helper once per menu button, so the exact class swap each tab needs lives at the call site rather than embedded in this helper.
313
+ *
314
+ * @param {string} id - The element ID to update.
315
+ * @param {string} removeClass - The class to remove.
316
+ * @param {string} addClass - The class to add.
317
+ * @private
318
+ */
182
319
  #toggleClasses(id, removeClass, addClass) {
183
320
 
184
321
  const element = document.getElementById(id);