homebridge-plugin-utils 1.35.0 → 2.0.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 (198) 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 +95 -0
  23. package/dist/cli/index.js +355 -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/eslint-plugin/config.d.mts +193 -0
  32. package/dist/eslint-plugin/index.d.mts +2 -0
  33. package/dist/eslint-plugin/plugin.d.mts +87 -0
  34. package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
  35. package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
  36. package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
  37. package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
  38. package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
  39. package/dist/featureOptions-docs.d.ts +96 -0
  40. package/dist/featureOptions-docs.js +193 -0
  41. package/dist/featureOptions-docs.js.map +1 -0
  42. package/dist/featureOptions.d.ts +674 -0
  43. package/dist/featureOptions.js +870 -0
  44. package/dist/featureOptions.js.map +1 -0
  45. package/dist/ffmpeg/codecs.d.ts +256 -72
  46. package/dist/ffmpeg/codecs.js +477 -262
  47. package/dist/ffmpeg/codecs.js.map +1 -1
  48. package/dist/ffmpeg/dgram-util.d.ts +46 -0
  49. package/dist/ffmpeg/dgram-util.js +38 -0
  50. package/dist/ffmpeg/dgram-util.js.map +1 -0
  51. package/dist/ffmpeg/exec.d.ts +83 -64
  52. package/dist/ffmpeg/exec.js +77 -86
  53. package/dist/ffmpeg/exec.js.map +1 -1
  54. package/dist/ffmpeg/fmp4.d.ts +49 -2
  55. package/dist/ffmpeg/fmp4.js +47 -11
  56. package/dist/ffmpeg/fmp4.js.map +1 -1
  57. package/dist/ffmpeg/hap-enums.d.ts +214 -0
  58. package/dist/ffmpeg/hap-enums.js +92 -0
  59. package/dist/ffmpeg/hap-enums.js.map +1 -0
  60. package/dist/ffmpeg/index.d.ts +16 -9
  61. package/dist/ffmpeg/index.js +6 -0
  62. package/dist/ffmpeg/index.js.map +1 -1
  63. package/dist/ffmpeg/mp4-assembler.d.ts +120 -0
  64. package/dist/ffmpeg/mp4-assembler.js +387 -0
  65. package/dist/ffmpeg/mp4-assembler.js.map +1 -0
  66. package/dist/ffmpeg/mp4-parser.d.ts +94 -0
  67. package/dist/ffmpeg/mp4-parser.js +130 -0
  68. package/dist/ffmpeg/mp4-parser.js.map +1 -0
  69. package/dist/ffmpeg/options.d.ts +62 -149
  70. package/dist/ffmpeg/options.js +608 -499
  71. package/dist/ffmpeg/options.js.map +1 -1
  72. package/dist/ffmpeg/process.d.ts +142 -96
  73. package/dist/ffmpeg/process.js +406 -278
  74. package/dist/ffmpeg/process.js.map +1 -1
  75. package/dist/ffmpeg/record.d.ts +302 -188
  76. package/dist/ffmpeg/record.js +408 -567
  77. package/dist/ffmpeg/record.js.map +1 -1
  78. package/dist/ffmpeg/recording-process-double.d.ts +144 -0
  79. package/dist/ffmpeg/recording-process-double.js +168 -0
  80. package/dist/ffmpeg/recording-process-double.js.map +1 -0
  81. package/dist/ffmpeg/rtp-parser.d.ts +70 -0
  82. package/dist/ffmpeg/rtp-parser.js +77 -0
  83. package/dist/ffmpeg/rtp-parser.js.map +1 -0
  84. package/dist/ffmpeg/rtp.d.ts +198 -141
  85. package/dist/ffmpeg/rtp.js +473 -251
  86. package/dist/ffmpeg/rtp.js.map +1 -1
  87. package/dist/ffmpeg/settings.d.ts +5 -2
  88. package/dist/ffmpeg/settings.js +20 -5
  89. package/dist/ffmpeg/settings.js.map +1 -1
  90. package/dist/ffmpeg/stream.d.ts +57 -107
  91. package/dist/ffmpeg/stream.js +121 -150
  92. package/dist/ffmpeg/stream.js.map +1 -1
  93. package/dist/formatters.d.ts +106 -0
  94. package/dist/formatters.js +174 -0
  95. package/dist/formatters.js.map +1 -0
  96. package/dist/homebridge-enums.d.ts +30 -0
  97. package/dist/homebridge-enums.js +17 -0
  98. package/dist/homebridge-enums.js.map +1 -0
  99. package/dist/index.d.ts +12 -6
  100. package/dist/index.js +7 -2
  101. package/dist/index.js.map +1 -1
  102. package/dist/logclient/auth.d.ts +114 -0
  103. package/dist/logclient/auth.js +199 -0
  104. package/dist/logclient/auth.js.map +1 -0
  105. package/dist/logclient/cli-run.d.ts +76 -0
  106. package/dist/logclient/cli-run.js +639 -0
  107. package/dist/logclient/cli-run.js.map +1 -0
  108. package/dist/logclient/cli.d.ts +3 -0
  109. package/dist/logclient/cli.js +97 -0
  110. package/dist/logclient/cli.js.map +1 -0
  111. package/dist/logclient/client.d.ts +145 -0
  112. package/dist/logclient/client.js +600 -0
  113. package/dist/logclient/client.js.map +1 -0
  114. package/dist/logclient/config.d.ts +173 -0
  115. package/dist/logclient/config.js +199 -0
  116. package/dist/logclient/config.js.map +1 -0
  117. package/dist/logclient/endpoints.d.ts +54 -0
  118. package/dist/logclient/endpoints.js +73 -0
  119. package/dist/logclient/endpoints.js.map +1 -0
  120. package/dist/logclient/filter.d.ts +45 -0
  121. package/dist/logclient/filter.js +51 -0
  122. package/dist/logclient/filter.js.map +1 -0
  123. package/dist/logclient/frame.d.ts +93 -0
  124. package/dist/logclient/frame.js +203 -0
  125. package/dist/logclient/frame.js.map +1 -0
  126. package/dist/logclient/index.d.ts +31 -0
  127. package/dist/logclient/index.js +12 -0
  128. package/dist/logclient/index.js.map +1 -0
  129. package/dist/logclient/parser.d.ts +211 -0
  130. package/dist/logclient/parser.js +393 -0
  131. package/dist/logclient/parser.js.map +1 -0
  132. package/dist/logclient/rest.d.ts +41 -0
  133. package/dist/logclient/rest.js +111 -0
  134. package/dist/logclient/rest.js.map +1 -0
  135. package/dist/logclient/settings.d.ts +15 -0
  136. package/dist/logclient/settings.js +64 -0
  137. package/dist/logclient/settings.js.map +1 -0
  138. package/dist/logclient/socket-double.d.ts +201 -0
  139. package/dist/logclient/socket-double.js +384 -0
  140. package/dist/logclient/socket-double.js.map +1 -0
  141. package/dist/logclient/socket.d.ts +257 -0
  142. package/dist/logclient/socket.js +620 -0
  143. package/dist/logclient/socket.js.map +1 -0
  144. package/dist/logclient/stitch.d.ts +83 -0
  145. package/dist/logclient/stitch.js +146 -0
  146. package/dist/logclient/stitch.js.map +1 -0
  147. package/dist/logclient/time-expression.d.ts +42 -0
  148. package/dist/logclient/time-expression.js +181 -0
  149. package/dist/logclient/time-expression.js.map +1 -0
  150. package/dist/logclient/time-window.d.ts +38 -0
  151. package/dist/logclient/time-window.js +53 -0
  152. package/dist/logclient/time-window.js.map +1 -0
  153. package/dist/logclient/types.d.ts +107 -0
  154. package/dist/logclient/types.js +6 -0
  155. package/dist/logclient/types.js.map +1 -0
  156. package/dist/mqttClient.d.ts +287 -0
  157. package/dist/mqttClient.js +433 -0
  158. package/dist/mqttClient.js.map +1 -0
  159. package/dist/service.d.ts +64 -15
  160. package/dist/service.js +93 -66
  161. package/dist/service.js.map +1 -1
  162. package/dist/ui/featureOptions.js +870 -0
  163. package/dist/ui/featureOptions.js.map +1 -0
  164. package/dist/ui/formatters.js +174 -0
  165. package/dist/ui/formatters.js.map +1 -0
  166. package/dist/ui/pluginConfigSession.mjs +141 -0
  167. package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
  168. package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
  169. package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
  170. package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
  171. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
  172. package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
  173. package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
  174. package/dist/ui/webUi-featureOptions/state.mjs +319 -0
  175. package/dist/ui/webUi-featureOptions/store.mjs +181 -0
  176. package/dist/ui/webUi-featureOptions/utils.mjs +200 -0
  177. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
  178. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
  179. package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
  180. package/dist/ui/webUi-featureOptions/views/nav.mjs +327 -0
  181. package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
  182. package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
  183. package/dist/ui/webUi-featureOptions.mjs +677 -0
  184. package/dist/ui/webUi.mjs +192 -87
  185. package/dist/util.d.ts +602 -45
  186. package/dist/util.js +783 -78
  187. package/dist/util.js.map +1 -1
  188. package/package.json +33 -15
  189. package/build/eslint-rules.mjs +0 -511
  190. package/dist/featureoptions.d.ts +0 -264
  191. package/dist/featureoptions.js +0 -480
  192. package/dist/featureoptions.js.map +0 -1
  193. package/dist/mqttclient.d.ts +0 -178
  194. package/dist/mqttclient.js +0 -310
  195. package/dist/mqttclient.js.map +0 -1
  196. package/dist/ui/featureoptions.js +0 -480
  197. package/dist/ui/featureoptions.js.map +0 -1
  198. package/dist/ui/webUi-featureoptions.mjs +0 -3765
package/dist/ui/webUi.mjs CHANGED
@@ -4,181 +4,286 @@
4
4
  */
5
5
  "use strict";
6
6
 
7
- import { webUiFeatureOptions } from "./webUi-featureoptions.mjs";
7
+ import { PluginConfigSession } from "./pluginConfigSession.mjs";
8
+ import { webUiFeatureOptions } from "./webUi-featureOptions.mjs";
8
9
 
10
+ /**
11
+ * @typedef {Object} FirstRunContext
12
+ * @property {(patch: Object) => Promise<void>} [commit] - Persist a patch to the primary platform-config entry. Supplied only to `onSubmit` (the one write hook).
13
+ * @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.
14
+ */
15
+
16
+ /**
17
+ * @typedef {Object} FirstRunHandlers
18
+ * @property {(context: FirstRunContext) => boolean | Promise<boolean>} [isRequired] - Returns truthy when the first-run flow must run before the main UI is shown.
19
+ * @property {(context: FirstRunContext) => boolean | Promise<boolean>} [onStart] - Initialization for the first-run UI; populates forms and runs any startup tasks.
20
+ * @property {(context: FirstRunContext) => boolean | Promise<boolean>} [onSubmit] - Executes the first-run workflow, typically a login or configuration validation.
21
+ */
22
+
23
+ /**
24
+ * @typedef {Object} WebUiConfig
25
+ * @property {Object} [featureOptions] - Parameters forwarded to {@link webUiFeatureOptions}.
26
+ * @property {FirstRunHandlers} [firstRun] - First-run lifecycle hooks.
27
+ * @property {string} [name] - Plugin name used to seed a fresh configuration.
28
+ */
29
+
30
+ /**
31
+ * webUi - Top-level plugin webUI orchestrator.
32
+ *
33
+ * 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
34
+ * single entry point Homebridge invokes to render the configuration UI; everything else - feature option discovery, theming, sidebar navigation, search - lives in
35
+ * the composed {@link webUiFeatureOptions} instance and its sub-components.
36
+ */
9
37
  export class webUi {
10
38
 
11
- // Feature options class instance.
12
39
  featureOptions;
13
40
 
14
- // First run webUI callback endpoints for customization.
15
41
  #firstRun;
16
-
17
- // Plugin name.
18
42
  #name;
43
+ #session;
19
44
 
20
45
  /**
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.
46
+ * Initialize the plugin webUI orchestrator.
47
+ *
48
+ * Constructs the composed {@link webUiFeatureOptions} instance immediately so the feature-options page is ready to render the moment the user navigates to it.
49
+ * 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
50
+ * `onSubmit` and the unspecified slots stay at the defaults that keep the flow driveable.
51
+ *
52
+ * @param {WebUiConfig} [options] - Configuration options for the webUI. All fields are optional; firstRun's hooks fall back to no-op handlers, and
53
+ * featureOptions/name simply default to undefined.
27
54
  */
28
55
  constructor({ featureOptions, firstRun = {}, name } = {}) {
29
56
 
30
- // Defaults for our first run handlers.
31
- this.#firstRun = { isRequired: () => false, onStart: () => true, onSubmit: () => true };
57
+ // 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
58
+ // assignment, so there is no intermediate object that gets discarded a line later.
59
+ this.#firstRun = { isRequired: () => false, onStart: () => true, onSubmit: () => true, ...firstRun };
32
60
 
33
- // Figure out the options passed in to us.
34
61
  this.featureOptions = new webUiFeatureOptions(featureOptions);
35
- this.#firstRun = Object.assign({}, this.#firstRun, firstRun);
36
62
  this.#name = name;
37
63
  }
38
64
 
39
65
  /**
40
66
  * Render the webUI.
67
+ *
68
+ * Public entry point Homebridge invokes when the configuration UI is opened. Delegates the actual rendering to {@link #launchWebUI}; this wrapper exists to
69
+ * 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
70
+ * 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
71
+ * rather than disappearing the moment the synchronous portion of the call returns.
72
+ *
73
+ * @returns {Promise<void>}
74
+ * @public
41
75
  */
42
- // Render the UI.
43
- show() {
76
+ async show() {
44
77
 
45
- // Fire off our UI, catching errors along the way.
46
78
  try {
47
79
 
48
- this.#launchWebUI();
80
+ await this.#launchWebUI();
49
81
  } catch(err) {
50
82
 
51
- // If we had an error instantiating or updating the UI, notify the user.
52
- homebridge.toast.error(err.message, "Error");
83
+ // Outermost user-facing diagnostic seam in the webUI. Caller-supplied first-run handlers and other extension points can throw any shape (strings, plain
84
+ // objects, primitives), so normalize before reaching the toast: `err?.message` extracts the message field when present, the nullish coalesce falls back
85
+ // to a string coercion of the whole value otherwise. This keeps the toast text useful regardless of what bubbled out of `#launchWebUI`.
86
+ homebridge.toast.error(err?.message ?? String(err), "Error");
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.
98
+ *
99
+ * @returns {Promise<void>}
100
+ * @private
101
+ */
61
102
  async #showFirstRun() {
62
103
 
63
104
  const buttonFirstRun = document.getElementById("firstRun");
64
105
 
65
- // Run a custom initialization handler the user may have provided.
66
- if(!(await this.#processHandler(this.#firstRun.onStart))) {
106
+ // 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
107
+ // (it pre-populates the form), so it receives config without the writer.
108
+ if(!(await this.#processHandler(this.#firstRun.onStart, { config: this.#session.platform }))) {
67
109
 
68
110
  return;
69
111
  }
70
112
 
71
- // We disable saving any settings until we configure the plugin.
72
113
  homebridge.disableSaveButton();
73
114
 
74
- // First run user experience.
75
115
  buttonFirstRun.addEventListener("click", async () => {
76
116
 
77
- // Show the beachball while we setup.
78
117
  homebridge.showSpinner();
79
118
 
80
- // Run a custom submit handler the user may have provided.
81
- if(!(await this.#processHandler(this.#firstRun.onSubmit))) {
119
+ try {
82
120
 
83
- return;
84
- }
121
+ // 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
122
+ // 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.
123
+ if(!(await this.#processHandler(this.#firstRun.onSubmit, { commit: (patch) => this.#session.commit(patch), config: this.#session.platform }))) {
85
124
 
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";
125
+ return;
126
+ }
127
+
128
+ // 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
129
+ // progressive disclosure - page-shell visible immediately, regions populating as their I/O resolves - so the click handler's spinner is the only one that
130
+ // brackets this transition. The `try/finally` ensures it comes down on every exit path, including the early bail above.
131
+ document.getElementById("pageFirstRun").style.display = "none";
132
+ document.getElementById("menuWrapper").style.display = "inline-flex";
89
133
 
90
- await this.featureOptions.show();
134
+ await this.featureOptions.show(this.#session);
91
135
 
92
- homebridge.enableSaveButton();
136
+ homebridge.enableSaveButton();
137
+ } finally {
93
138
 
94
- // All done. Let the user interact with us, although in practice, we shouldn't get here.
95
- // homebridge.hideSpinner();
139
+ homebridge.hideSpinner();
140
+ }
96
141
  });
97
142
 
98
143
  document.getElementById("pageFirstRun").style.display = "block";
99
144
  }
100
145
 
101
- // Show the main plugin configuration tab.
102
- #showSettings() {
146
+ /**
147
+ * Show the main plugin configuration tab.
148
+ *
149
+ * Hides the feature-options view, swaps the menu button states (home and feature-options become primary; settings becomes elegant to indicate the active tab),
150
+ * 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.
151
+ *
152
+ * Awaits `featureOptions.hide()` BEFORE revealing the schema form so any debounced-but-unwritten option edit is flushed into Homebridge's in-memory config model
153
+ * 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
154
+ * 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.
155
+ *
156
+ * @returns {Promise<void>}
157
+ * @private
158
+ */
159
+ async #showSettings() {
103
160
 
104
- // Show the beachball while we setup.
105
161
  homebridge.showSpinner();
106
- this.featureOptions.hide();
107
162
 
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");
163
+ try {
164
+
165
+ await this.featureOptions.hide();
166
+ } finally {
167
+
168
+ this.#toggleClasses("menuHome", "btn-elegant", "btn-primary");
169
+ this.#toggleClasses("menuFeatureOptions", "btn-elegant", "btn-primary");
170
+ this.#toggleClasses("menuSettings", "btn-primary", "btn-elegant");
112
171
 
113
- document.getElementById("pageSupport").style.display = "none";
114
- document.getElementById("pageFeatureOptions").style.display = "none";
172
+ document.getElementById("pageSupport").style.display = "none";
173
+ document.getElementById("pageFeatureOptions").style.display = "none";
115
174
 
116
- homebridge.showSchemaForm();
175
+ homebridge.showSchemaForm();
117
176
 
118
- // All done. Let the user interact with us.
119
- homebridge.hideSpinner();
177
+ homebridge.hideSpinner();
178
+ }
120
179
  }
121
180
 
122
- // Show the support tab.
123
- #showSupport() {
181
+ /**
182
+ * Show the support tab.
183
+ *
184
+ * 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
185
+ * to primary), and reveals the static support page. Spinner brackets the swap to mask transient layout shifts.
186
+ *
187
+ * Awaits `featureOptions.hide()` BEFORE revealing the support page so any debounced-but-unwritten option edit is flushed first, matching the Settings path. The
188
+ * 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`).
189
+ *
190
+ * @returns {Promise<void>}
191
+ * @private
192
+ */
193
+ async #showSupport() {
124
194
 
125
- // Show the beachball while we setup.
126
195
  homebridge.showSpinner();
127
196
  homebridge.hideSchemaForm();
128
- this.featureOptions.hide();
129
197
 
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");
198
+ try {
199
+
200
+ await this.featureOptions.hide();
201
+ } finally {
202
+
203
+ this.#toggleClasses("menuHome", "btn-primary", "btn-elegant");
204
+ this.#toggleClasses("menuFeatureOptions", "btn-elegant", "btn-primary");
205
+ this.#toggleClasses("menuSettings", "btn-elegant", "btn-primary");
134
206
 
135
- document.getElementById("pageSupport").style.display = "block";
136
- document.getElementById("pageFeatureOptions").style.display = "none";
207
+ document.getElementById("pageSupport").style.display = "block";
208
+ document.getElementById("pageFeatureOptions").style.display = "none";
137
209
 
138
- // All done. Let the user interact with us.
139
- homebridge.hideSpinner();
210
+ homebridge.hideSpinner();
211
+ }
140
212
  }
141
213
 
142
- // Launch our webUI.
214
+ /**
215
+ * Launch the webUI.
216
+ *
217
+ * 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
218
+ * 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
219
+ * one config owner rather than fetching it independently.
220
+ *
221
+ * @returns {Promise<void>}
222
+ * @private
223
+ */
143
224
  async #launchWebUI() {
144
225
 
145
- // Retrieve the current plugin configuration.
146
- this.featureOptions.currentConfig = await homebridge.getPluginConfig();
226
+ // 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
227
+ // 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
228
+ // later reader sees.
229
+ this.#session = await PluginConfigSession.open({ host: homebridge, name: this.#name });
147
230
 
148
- // Add our event listeners to animate the UI.
231
+ // Menu click listeners use a uniform shape: an arrow expression that calls the handler and returns its result. addEventListener discards the return value, so an
232
+ // async handler's promise is dropped either way; wrapping `featureOptions.show()` in `async () => await ...` would add a microtask hop and break the visual
233
+ // symmetry across the three sibling registrations for no behavioral benefit. All three handlers are async (show() syncs; #showSettings / #showSupport await
234
+ // the navigate-away flush before revealing the next tab), and the dropped promise is a deliberate fire-and-forget: each handler's own try/finally reveals the tab
235
+ // and drains the spinner on every path, and the flush drain's failure surfaces internally via persist:failed's toast - so there is no unobserved rejection here.
149
236
  document.getElementById("menuHome").addEventListener("click", () => this.#showSupport());
150
- document.getElementById("menuFeatureOptions").addEventListener("click", async () => await this.featureOptions.show());
237
+ document.getElementById("menuFeatureOptions").addEventListener("click", () => this.featureOptions.show(this.#session));
151
238
  document.getElementById("menuSettings").addEventListener("click", () => this.#showSettings());
152
239
 
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))) {
240
+ // 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
241
+ // 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
242
+ // 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
243
+ // flow can persist credentials on submit without a separate eager write here.
244
+ if(!(await this.#processHandler(this.#firstRun.isRequired, { config: this.#session.platform }))) {
155
245
 
156
246
  document.getElementById("menuWrapper").style.display = "inline-flex";
157
- await this.featureOptions.show();
247
+ await this.featureOptions.show(this.#session);
158
248
 
159
249
  return;
160
250
  }
161
251
 
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();
252
+ // 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,
253
+ // 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
254
+ // while initialization is still in flight, leaving the user looking at a half-rendered first-run UI.
255
+ await this.#showFirstRun();
168
256
  }
169
257
 
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
- }
258
+ /**
259
+ * Resolve a caller-supplied handler whose shape may be a function or a plain truthy/falsy value.
260
+ *
261
+ * 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
262
+ * 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
263
+ * 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
264
+ * write seam) rather than reaching for the session or the host itself.
265
+ *
266
+ * @param {Function|*} handler - Caller-supplied handler. When a function, it is awaited; otherwise it is treated as a truthy/falsy continuation flag.
267
+ * @param {FirstRunContext} [context] - The injected context forwarded to the function form of the handler.
268
+ * @returns {Promise<boolean>} `true` when the workflow should continue, `false` when it should be aborted.
269
+ * @private
270
+ */
271
+ async #processHandler(handler, context) {
177
272
 
178
- return true;
273
+ return Boolean((typeof handler === "function") ? await handler(context) : handler);
179
274
  }
180
275
 
181
- // Utility to toggle our classes.
276
+ /**
277
+ * Swap one Bootstrap button class for another on a DOM element.
278
+ *
279
+ * 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
280
+ * helper once per menu button, so the exact class swap each tab needs lives at the call site rather than embedded in this helper.
281
+ *
282
+ * @param {string} id - The element ID to update.
283
+ * @param {string} removeClass - The class to remove.
284
+ * @param {string} addClass - The class to add.
285
+ * @private
286
+ */
182
287
  #toggleClasses(id, removeClass, addClass) {
183
288
 
184
289
  const element = document.getElementById(id);