@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.4 → 5.0.0-beta.41

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 (121) hide show
  1. package/CONTRIBUTING.md +9 -17
  2. package/config.schema.json +60 -0
  3. package/dist/account/ownership.js +16 -16
  4. package/dist/account/ownership.js.map +1 -1
  5. package/dist/account/persistence.js +24 -74
  6. package/dist/account/persistence.js.map +1 -1
  7. package/dist/account/temporary-authentication.js +5 -3
  8. package/dist/account/temporary-authentication.js.map +1 -1
  9. package/dist/configuration.js +50 -13
  10. package/dist/configuration.js.map +1 -1
  11. package/dist/device/member-evidence.js +11 -4
  12. package/dist/device/member-evidence.js.map +1 -1
  13. package/dist/device/member-trust.js +14 -32
  14. package/dist/device/member-trust.js.map +1 -1
  15. package/dist/device/snapshot.js +6 -18
  16. package/dist/device/snapshot.js.map +1 -1
  17. package/dist/diagnostics.js +966 -337
  18. package/dist/diagnostics.js.map +1 -1
  19. package/dist/homekit/adapter.js +4 -1
  20. package/dist/homekit/adapter.js.map +1 -1
  21. package/dist/homekit/adapters/battery.js +3 -3
  22. package/dist/homekit/adapters/battery.js.map +1 -1
  23. package/dist/homekit/adapters/camera-controls.js +15 -37
  24. package/dist/homekit/adapters/camera-controls.js.map +1 -1
  25. package/dist/homekit/adapters/camera-streaming.js +74 -48
  26. package/dist/homekit/adapters/camera-streaming.js.map +1 -1
  27. package/dist/homekit/adapters/contact.js +5 -5
  28. package/dist/homekit/adapters/contact.js.map +1 -1
  29. package/dist/homekit/adapters/doorbell.js +3 -15
  30. package/dist/homekit/adapters/doorbell.js.map +1 -1
  31. package/dist/homekit/adapters/lock.js +72 -221
  32. package/dist/homekit/adapters/lock.js.map +1 -1
  33. package/dist/homekit/adapters/motion.js +3 -3
  34. package/dist/homekit/adapters/motion.js.map +1 -1
  35. package/dist/homekit/adapters/package-sensor.js +63 -0
  36. package/dist/homekit/adapters/package-sensor.js.map +1 -0
  37. package/dist/homekit/adapters/registry.js +2 -0
  38. package/dist/homekit/adapters/registry.js.map +1 -1
  39. package/dist/homekit/adapters/security-system.js +106 -250
  40. package/dist/homekit/adapters/security-system.js.map +1 -1
  41. package/dist/homekit/adapters/siren.js +5 -33
  42. package/dist/homekit/adapters/siren.js.map +1 -1
  43. package/dist/homekit/adapters/smart-light.js +19 -217
  44. package/dist/homekit/adapters/smart-light.js.map +1 -1
  45. package/dist/homekit/coverage-matrix.js +8 -67
  46. package/dist/homekit/coverage-matrix.js.map +1 -1
  47. package/dist/homekit/device-control.js +249 -13
  48. package/dist/homekit/device-control.js.map +1 -1
  49. package/dist/homekit/reconciler.js +25 -9
  50. package/dist/homekit/reconciler.js.map +1 -1
  51. package/dist/media/last-successful-image.js +8 -23
  52. package/dist/media/last-successful-image.js.map +1 -1
  53. package/dist/media/live-preview.js +217 -0
  54. package/dist/media/live-preview.js.map +1 -0
  55. package/dist/media/live-stream.js +689 -160
  56. package/dist/media/live-stream.js.map +1 -1
  57. package/dist/media/recording.js +41 -118
  58. package/dist/media/recording.js.map +1 -1
  59. package/dist/media/snapshot.js +22 -17
  60. package/dist/media/snapshot.js.map +1 -1
  61. package/dist/media/station-live-sessions.js +40 -51
  62. package/dist/media/station-live-sessions.js.map +1 -1
  63. package/dist/platform.js +61 -10
  64. package/dist/platform.js.map +1 -1
  65. package/dist/runtime/channel.js +227 -40
  66. package/dist/runtime/channel.js.map +1 -1
  67. package/dist/runtime/device-settings.js +88 -0
  68. package/dist/runtime/device-settings.js.map +1 -0
  69. package/dist/runtime/owner.js +30 -5
  70. package/dist/runtime/owner.js.map +1 -1
  71. package/dist/runtime/sdk-client.js +21 -17
  72. package/dist/runtime/sdk-client.js.map +1 -1
  73. package/dist/runtime/tracker.js +8 -38
  74. package/dist/runtime/tracker.js.map +1 -1
  75. package/dist/storage.js +1 -1
  76. package/dist/storage.js.map +1 -1
  77. package/dist/ui/dashboard.js +36 -16
  78. package/dist/ui/dashboard.js.map +1 -1
  79. package/dist/ui/runtime-channel-client.js +155 -184
  80. package/dist/ui/runtime-channel-client.js.map +1 -1
  81. package/dist/ui/server.js +94 -41
  82. package/dist/ui/server.js.map +1 -1
  83. package/homebridge-ui/public/app.css +531 -239
  84. package/homebridge-ui/public/assets/icons/auto_fix_high.svg +1 -0
  85. package/homebridge-ui/public/assets/icons/battery_0.svg +1 -0
  86. package/homebridge-ui/public/assets/icons/battery_1.svg +1 -0
  87. package/homebridge-ui/public/assets/icons/battery_2.svg +1 -0
  88. package/homebridge-ui/public/assets/icons/battery_3.svg +1 -0
  89. package/homebridge-ui/public/assets/icons/battery_4.svg +1 -0
  90. package/homebridge-ui/public/assets/icons/battery_5.svg +1 -0
  91. package/homebridge-ui/public/assets/icons/battery_6.svg +1 -0
  92. package/homebridge-ui/public/assets/icons/battery_full.svg +1 -0
  93. package/homebridge-ui/public/assets/icons/contact_support.svg +1 -0
  94. package/homebridge-ui/public/assets/icons/delete.svg +1 -0
  95. package/homebridge-ui/public/assets/icons/download.svg +1 -0
  96. package/homebridge-ui/public/assets/icons/extension_off.svg +1 -0
  97. package/homebridge-ui/public/assets/icons/info.svg +1 -0
  98. package/homebridge-ui/public/assets/icons/key_off.svg +1 -0
  99. package/homebridge-ui/public/assets/icons/keyboard_arrow_left.svg +1 -0
  100. package/homebridge-ui/public/assets/icons/keyboard_arrow_right.svg +1 -0
  101. package/homebridge-ui/public/assets/icons/keyboard_double_arrow_left.svg +1 -0
  102. package/homebridge-ui/public/assets/icons/keyboard_double_arrow_right.svg +1 -0
  103. package/homebridge-ui/public/assets/icons/movie_off.svg +1 -0
  104. package/homebridge-ui/public/assets/icons/play_arrow.svg +1 -0
  105. package/homebridge-ui/public/assets/icons/refresh.svg +1 -0
  106. package/homebridge-ui/public/assets/icons/satellite_alt.svg +1 -0
  107. package/homebridge-ui/public/assets/icons/search_off.svg +1 -0
  108. package/homebridge-ui/public/assets/icons/sensors_off.svg +1 -0
  109. package/homebridge-ui/public/assets/icons/settings_photo_camera.svg +1 -0
  110. package/homebridge-ui/public/assets/icons/solar_power.svg +1 -0
  111. package/homebridge-ui/public/assets/icons/stop.svg +1 -0
  112. package/homebridge-ui/public/assets/icons/videocam_off.svg +1 -0
  113. package/homebridge-ui/public/i18n/en.json +161 -82
  114. package/homebridge-ui/public/i18n/fr.json +162 -83
  115. package/homebridge-ui/public/index.html +224 -96
  116. package/homebridge-ui/public/js/app.js +821 -435
  117. package/homebridge-ui/public/js/dashboard.js +148 -31
  118. package/i18n/runtime/en.json +10 -3
  119. package/package.json +10 -11
  120. package/homebridge-ui/public/js/legacy-settings.js +0 -37
  121. package/homebridge-ui/public/js/profile-wizard.js +0 -61
@@ -19,9 +19,15 @@ const challengeSubmit = document.querySelector('[data-challenge-submit]');
19
19
  const dashboard = document.querySelector('[data-dashboard]');
20
20
  const dashboardState = document.querySelector('[data-dashboard-state]');
21
21
  const dashboardTitle = document.querySelector('[data-dashboard-title]');
22
- const dashboardBadge = document.querySelector('[data-dashboard-badge]');
23
22
  const dashboardSummary = document.querySelector('[data-dashboard-summary]');
24
23
  const dashboardAuthenticate = document.querySelector('[data-dashboard-authenticate]');
24
+ const reauthDialog = document.querySelector('[data-reauth-dialog]');
25
+ const reauthAction = document.querySelector('[data-reauth-action]');
26
+ const attentionDialog = document.querySelector('[data-attention-dialog]');
27
+ const attentionTitle = document.querySelector('[data-attention-title]');
28
+ const attentionSummary = document.querySelector('[data-attention-summary]');
29
+ const attentionDiagnose = document.querySelector('[data-attention-diagnose]');
30
+ const attentionChoose = document.querySelector('[data-attention-choose]');
25
31
  const deviceGroups = document.querySelector('[data-device-groups]');
26
32
  const updatePending = document.querySelector('[data-update-pending]');
27
33
  const updatePendingSummary = document.querySelector('[data-update-pending-summary]');
@@ -31,48 +37,34 @@ const pageTitle = document.querySelector('[data-page-title]');
31
37
  const legacyNotice = document.querySelector('[data-legacy-notice]');
32
38
  const legacySettings = document.querySelector('[data-legacy-settings]');
33
39
  const legacyAcknowledge = document.querySelector('[data-legacy-acknowledge]');
40
+ const legacyStatus = document.querySelector('[data-legacy-status]');
34
41
  const menuDiagnostics = document.querySelector('[data-menu-diagnostics]');
35
42
  const menuAdvanced = document.querySelector('[data-menu-advanced]');
36
43
  const diagnosticsPanel = document.querySelector('[data-diagnostics]');
37
44
  const mastheadDiagnostics = document.querySelector('[data-masthead-diagnostics]');
38
45
  const diagnosticsClose = document.querySelector('[data-diagnostics-close]');
39
46
  const diagnosticsWizardPanel = document.querySelector('[data-diagnostics-wizard]');
40
- const diagnosticsQuestion = document.querySelector('[data-diagnostics-question]');
41
47
  const diagnosticsQuestionText = document.querySelector('[data-diagnostics-question-text]');
42
48
  const diagnosticsTiles = [...document.querySelectorAll('[data-diagnostics-tile]')];
43
- const diagnosticsDevices = document.querySelector('[data-diagnostics-devices]');
44
- const diagnosticsDeviceList = document.querySelector('[data-diagnostics-device-list]');
45
- const diagnosticsDevicesEvery = document.querySelector('[data-diagnostics-devices-every]');
46
- const diagnosticsDevicesChosen = document.querySelector('[data-diagnostics-devices-chosen]');
47
- const diagnosticsFrequency = document.querySelector('[data-diagnostics-frequency]');
48
- const diagnosticsFrequencyHeading = document.querySelector('#diagnostics-frequency-heading');
49
- const diagnosticsFrequencyNow = document.querySelector('[data-diagnostics-frequency-answer="now"]');
50
- const diagnosticsFrequencyIntermittent = document.querySelector('[data-diagnostics-frequency-answer="intermittent"]');
51
- const diagnosticsFrequencyBack = document.querySelector('[data-diagnostics-frequency-back]');
52
- const diagnosticsMatch = document.querySelector('[data-diagnostics-match]');
53
- const diagnosticsReject = document.querySelector('[data-diagnostics-reject]');
54
- const diagnosticsAuthorize = document.querySelector('[data-diagnostics-authorize]');
49
+ const diagnosticsCancel = document.querySelector('[data-diagnostics-cancel]');
55
50
  const diagnosticsReproduction = document.querySelector('[data-diagnostics-reproduction]');
56
51
  const diagnosticsStatus = document.querySelector('[data-diagnostics-status]');
57
52
  const diagnosticsIssue = document.querySelector('[data-diagnostics-issue]');
58
- const diagnosticsIssueHint = document.querySelector('[data-diagnostics-issue-hint]');
53
+ const diagnosticsExistingIssue = document.querySelector('[data-diagnostics-existing-issue]');
59
54
  const diagnosticsResult = document.querySelector('[data-diagnostics-result]');
60
55
  const diagnosticsActions = document.querySelector('[data-diagnostics-actions]');
61
- const diagnosticsGuidanceTitle = document.querySelector('[data-diagnostics-guidance-title]');
62
- const diagnosticsModeSummary = document.querySelector('[data-diagnostics-mode-summary]');
63
56
  const diagnosticsGuidance = document.querySelector('[data-diagnostics-guidance]');
64
57
  const diagnosticsPhaseTitle = document.querySelector('[data-diagnostics-phase-title]');
65
- const diagnosticsGuidanceBeforeSection = document.querySelector('[data-diagnostics-guidance-before-section]');
66
- const diagnosticsGuidanceBefore = document.querySelector('[data-diagnostics-guidance-before]');
67
58
  const diagnosticsGuidanceAction = document.querySelector('[data-diagnostics-guidance-action]');
68
- const diagnosticsManifest = document.querySelector('[data-diagnostics-manifest]');
69
- const diagnosticsReviewConfirm = document.querySelector('[data-diagnostics-review-confirm]');
70
- const diagnosticsReviewConfirmLabel = document.querySelector('[data-diagnostics-review-confirm-label]');
59
+ const diagnosticsCaptureNote = document.querySelector('[data-diagnostics-capture-note]');
60
+ const diagnosticsHandoff = document.querySelector('[data-diagnostics-handoff]');
61
+ const diagnosticsHandoffNote = document.querySelector('[data-diagnostics-handoff-note]');
62
+ const diagnosticsResultStatus = document.querySelector('[data-diagnostics-result-status]');
71
63
  const diagnosticsExport = document.querySelector('[data-diagnostics-export]');
72
64
  const diagnosticsResultHeading = document.querySelector('[data-diagnostics-result-heading]');
73
65
  const diagnosticsStartAnother = document.querySelector('[data-diagnostics-start-another]');
66
+ const diagnosticsDownloadAgain = document.querySelector('[data-diagnostics-download-again]');
74
67
  const advancedPanel = document.querySelector('[data-advanced-settings]');
75
- const dashboardDiagnose = document.querySelector('[data-dashboard-diagnose]');
76
68
  const devicePanel = document.querySelector('[data-device-settings]');
77
69
  const deviceClose = document.querySelector('[data-device-close]');
78
70
  const deviceSettingsTitle = document.querySelector('[data-device-settings-title]');
@@ -82,6 +74,11 @@ const advancedClose = document.querySelector('[data-advanced-close]');
82
74
  const advancedPolling = document.querySelector('[data-advanced-polling]');
83
75
  const advancedConcurrentMedia = document.querySelector('[data-advanced-concurrent-media]');
84
76
  const advancedFfmpeg = document.querySelector('[data-advanced-ffmpeg]');
77
+ const advancedSmallVideoPackets = document.querySelector('[data-advanced-small-video-packets]');
78
+ const advancedLiveVideoPassthrough = document.querySelector('[data-advanced-live-video-passthrough]');
79
+ const advancedHardwareVideoEncoder = document.querySelector('[data-advanced-hardware-video-encoder]');
80
+ const advancedHdLiveVideo = document.querySelector('[data-advanced-hd-live-video]');
81
+ const advancedIgnoreHomeBitRate = document.querySelector('[data-advanced-ignore-home-bit-rate]');
85
82
  const warmUpAvailable = document.querySelector('[data-warm-up-available]');
86
83
  const warmUpChosen = document.querySelector('[data-warm-up-chosen]');
87
84
  const warmUpAdd = document.querySelector('[data-warm-up-add]');
@@ -89,6 +86,34 @@ const warmUpAddAll = document.querySelector('[data-warm-up-add-all]');
89
86
  const warmUpRemove = document.querySelector('[data-warm-up-remove]');
90
87
  const warmUpRemoveAll = document.querySelector('[data-warm-up-remove-all]');
91
88
  const advancedStatus = document.querySelector('[data-advanced-status]');
89
+ const menuWizard = document.querySelector('[data-menu-wizard]');
90
+ const wizardPanel = document.querySelector('[data-setup-wizard]');
91
+ const wizardClose = document.querySelector('[data-wizard-close]');
92
+ const wizardProgress = document.querySelector('[data-wizard-progress]');
93
+ const wizardTitle = document.querySelector('[data-wizard-title]');
94
+ const wizardEyebrow = document.querySelector('[data-wizard-eyebrow]');
95
+ const wizardRestart = document.querySelector('[data-wizard-restart]');
96
+ const wizardLive = document.querySelector('[data-wizard-live]');
97
+ const wizardLiveImage = document.querySelector('[data-wizard-live-image]');
98
+ const wizardLiveHomeKit = document.querySelector('[data-wizard-live-homekit]');
99
+ const wizardLiveCamera = document.querySelector('[data-wizard-live-camera]');
100
+ const wizardLiveStatus = document.querySelector('[data-wizard-live-status]');
101
+ const wizardLivePlay = document.querySelector('[data-wizard-live-play]');
102
+ const wizardLiveStop = document.querySelector('[data-wizard-live-stop]');
103
+ const wizardLiveSpinner = document.querySelector('[data-wizard-live-spinner]');
104
+ const wizardLiveHomeKitSpinner = document.querySelector('[data-wizard-live-homekit-spinner]');
105
+ const wizardLiveControls = document.querySelector('[data-wizard-live-controls]');
106
+ const wizardLiveSave = document.querySelector('[data-wizard-live-save]');
107
+ const wizardLiveEncoder = document.querySelector('[data-wizard-live-encoder]');
108
+ const wizardLiveDiagnose = document.querySelector('[data-wizard-live-diagnose]');
109
+ const wizardLiveDiagnoseStart = document.querySelector('[data-wizard-live-diagnose-start]');
110
+ const wizardSettings = document.querySelector('[data-wizard-settings]');
111
+ const wizardControls = document.querySelector('[data-wizard-controls]');
112
+ const wizardFinish = document.querySelector('[data-wizard-finish]');
113
+ const wizardMap = document.querySelector('[data-wizard-map]');
114
+ const wizardStage = document.querySelector('[data-wizard-stage]');
115
+ const wizardHomeKit = document.querySelector('[data-wizard-homekit]');
116
+ const wizardEufy = document.querySelector('[data-wizard-eufy]');
92
117
 
93
118
  let messages = {};
94
119
  let pluginConfig = [];
@@ -100,28 +125,26 @@ let legacyAcknowledged = false;
100
125
  let diagnosticsState = { status: 'inactive', missingEvidence: [] };
101
126
  /** Whether the reporter already downloaded this session's archive, which is what the report offer follows. */
102
127
  let diagnosticsArchiveDownloaded = false;
103
- /** The session whose manifest is on screen, so it is fetched once rather than on every render. */
128
+ /** The session whose archive is reviewed and ready to export, so the review is fetched once rather than per render. */
104
129
  let diagnosticsReviewedCaseId = '';
105
- /** Guards the one fetch, so a burst of renders does not assemble the manifest several times over. */
106
- let diagnosticsReviewPending = false;
130
+ /** The one fetch in flight, so a burst of renders does not assemble the archive several times over. */
131
+ let diagnosticsReviewRequest;
107
132
  let diagnosticsReviewId = '';
108
- let diagnosticsStartingAnother = false;
109
133
  let panelReturn;
134
+ /** The problem the dashboard last drew, which the dashboard's diagnostics action points at while there is one. */
135
+ let dashboardAttention;
110
136
  let dashboardPanelTrigger;
111
137
  const dashboardView = window.HomebridgeEufyDashboard;
112
- const legacySettingsView = window.HomebridgeEufyLegacySettings;
113
- const diagnosticsWizard = window.HomebridgeEufyDiagnosticsWizard;
114
- let diagnosticsWizardState = diagnosticsWizard.start();
115
138
  const dashboardElements = {
116
139
  dashboard,
117
140
  title: dashboardTitle,
118
- diagnose: dashboardDiagnose,
119
141
  summary: dashboardSummary,
120
142
  authenticate: dashboardAuthenticate,
121
143
  groups: deviceGroups,
122
144
  deviceSettingsTitle,
123
145
  deviceSettingsEyebrow,
124
146
  deviceSettingsControls,
147
+ preferenceContainers: [deviceSettingsControls, wizardControls],
125
148
  updatePending,
126
149
  updatePendingSummary,
127
150
  updatePendingVersion,
@@ -131,16 +154,23 @@ const dashboardElements = {
131
154
  masthead,
132
155
  };
133
156
 
157
+ const REQUEST_TIMED_OUT = 'Request timed out';
158
+
159
+ /**
160
+ * A request the page stops waiting for after `timeoutMs`. The timeout error carries the request as `late`, since
161
+ * the plugin may still carry it out after the page gave up.
162
+ */
134
163
  function requestWithinDeadline(path, body, timeoutMs = 320000) {
135
164
  let timer;
165
+ const request = homebridge.request(path, body);
136
166
  const timeout = new Promise((_, reject) => {
137
- timer = setTimeout(() => reject(new Error('Request timed out')), timeoutMs);
167
+ timer = setTimeout(() => reject(Object.assign(new Error(REQUEST_TIMED_OUT), { late: request })), timeoutMs);
138
168
  });
139
- return Promise.race([homebridge.request(path, body), timeout]).finally(() => clearTimeout(timer));
169
+ return Promise.race([request, timeout]).finally(() => clearTimeout(timer));
140
170
  }
141
171
 
142
172
  function isDashboardUiReproducing(state = diagnosticsState) {
143
- return diagnosticsWizard.backgroundActive(state);
173
+ return state.status === 'reproducing' && state.profile === 'dashboard-ui';
144
174
  }
145
175
 
146
176
  async function recordActiveUiEvent(event) {
@@ -152,6 +182,11 @@ function recordActiveUiEventBestEffort(event) {
152
182
  void recordActiveUiEvent(event).catch(() => undefined);
153
183
  }
154
184
 
185
+ /** A country code that is not two letters is refused where it is typed, in the page's own words. */
186
+ countryInput.addEventListener('input', () => {
187
+ countryInput.setCustomValidity(countryInput.validity.patternMismatch ? (messages.countryInvalid ?? '') : '');
188
+ });
189
+
155
190
  acknowledgement.addEventListener('change', () => {
156
191
  continueButton.disabled = !acknowledgement.checked;
157
192
  });
@@ -210,125 +245,29 @@ function setBusy(busy) {
210
245
  }
211
246
 
212
247
  const diagnosticsProfiles = {
213
- 'startup-authentication': {
214
- title: 'diagnosticsProfileStartup',
215
- summary: 'diagnosticsStartupSummary',
216
- before: 'diagnosticsStartupBefore',
217
- action: 'diagnosticsStartupAction',
218
- },
219
- 'device-representation': {
220
- title: 'diagnosticsProfileDevices',
221
- summary: 'diagnosticsDevicesSummary',
222
- before: 'diagnosticsDevicesBefore',
223
- action: 'diagnosticsDevicesAction',
224
- },
225
- 'control-state': {
226
- title: 'diagnosticsProfileControl',
227
- summary: 'diagnosticsControlSummary',
228
- before: 'diagnosticsControlBefore',
229
- action: 'diagnosticsControlAction',
230
- },
231
- 'live-media': {
232
- title: 'diagnosticsProfileLiveMedia',
233
- summary: 'diagnosticsLiveSummary',
234
- before: 'diagnosticsLiveBefore',
235
- action: 'diagnosticsLiveAction',
236
- },
237
- 'hksv-recording': {
238
- title: 'diagnosticsProfileRecording',
239
- summary: 'diagnosticsRecordingSummary',
240
- before: 'diagnosticsRecordingBefore',
241
- action: 'diagnosticsRecordingAction',
242
- },
243
- 'dashboard-ui': {
244
- title: 'diagnosticsProfileDashboard',
245
- summary: 'diagnosticsDashboardSummary',
246
- before: 'diagnosticsDashboardBefore',
247
- action: 'diagnosticsDashboardAction',
248
- },
249
- other: {
250
- title: 'diagnosticsProfileOther',
251
- summary: 'diagnosticsOtherSummary',
252
- before: 'diagnosticsOtherBefore',
253
- action: 'diagnosticsOtherAction',
254
- },
248
+ 'startup-authentication': { title: 'diagnosticsProfileStartup', action: 'diagnosticsStartupAction' },
249
+ 'device-representation': { title: 'diagnosticsProfileDevices', action: 'diagnosticsDevicesAction' },
250
+ 'control-state': { title: 'diagnosticsProfileControl', action: 'diagnosticsControlAction' },
251
+ 'live-media': { title: 'diagnosticsProfileLiveMedia', action: 'diagnosticsLiveAction' },
252
+ 'hksv-recording': { title: 'diagnosticsProfileRecording', action: 'diagnosticsRecordingAction' },
253
+ 'dashboard-ui': { title: 'diagnosticsProfileDashboard', action: 'diagnosticsDashboardAction' },
254
+ other: { title: 'diagnosticsProfileOther', action: 'diagnosticsOtherAction' },
255
255
  };
256
256
 
257
- function renderDiagnosticsGuidance(profile) {
258
- const guidance = diagnosticsProfiles[profile] ?? diagnosticsProfiles.other;
259
- diagnosticsGuidanceTitle.textContent = messages[guidance.title] ?? '';
260
- diagnosticsPhaseTitle.textContent = messages[guidance.title] ?? '';
261
- diagnosticsGuidanceBefore.textContent = messages[guidance.before] ?? '';
262
- diagnosticsGuidanceAction.textContent = messages[guidance.action] ?? '';
263
- }
264
-
265
257
  /**
266
- * The devices the reporter may name, one tile each.
267
- *
268
- * A tile carries the device's own name above its retained image where it has one, and the icon the devices
269
- * view uses for a device where it does not. The image is capped at half the tile: it is there to be recognised
270
- * at a glance, and a picture that fills the tile leaves the name it belongs to competing with it.
271
- *
272
- * The list is asked for when it is empty rather than relying on the devices view having been opened first, and
273
- * a request that goes unanswered leaves the step able to say every device, which is a complete answer.
258
+ * Which screen a session puts on the panel: its file while one can be downloaded, its capture while one runs, and
259
+ * otherwise the question, since picking an area replaces the session with a capture of that area.
274
260
  */
275
- async function renderDiagnosticsDeviceList() {
276
- if (dashboardDevices.length === 0) {
277
- try {
278
- const snapshot = await requestWithinDeadline('/dashboard', { representationPreferences: {} }, 12000);
279
- dashboardDevices = snapshot.devices ?? [];
280
- } catch {
281
- dashboardDevices = [];
282
- }
283
- }
284
- diagnosticsDeviceList.replaceChildren();
285
- for (const device of dashboardDevices) {
286
- const tile = document.createElement('button');
287
- tile.type = 'button';
288
- tile.dataset.serial = device.serial;
289
- tile.setAttribute('aria-pressed', 'false');
290
- const art = document.createElement('img');
291
- art.alt = '';
292
- if (device.artwork) {
293
- art.className = 'device-photo';
294
- art.loading = 'lazy';
295
- art.src = device.artwork;
296
- } else {
297
- art.src = 'assets/icons/inventory.svg';
298
- }
299
- const name = document.createElement('span');
300
- name.textContent = device.name;
301
- tile.append(art, name);
302
- tile.addEventListener('click', () => {
303
- tile.setAttribute('aria-pressed', tile.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
304
- diagnosticsDevicesChosen.disabled = chosenDiagnosticsDevices().length === 0;
305
- });
306
- diagnosticsDeviceList.append(tile);
307
- }
308
- diagnosticsDevicesChosen.disabled = true;
261
+ function diagnosticsScreen(session) {
262
+ if (session.partialExportAvailable) return 'review';
263
+ if (session.status === 'reproducing') return 'reproduce';
264
+ return 'choose';
309
265
  }
310
266
 
311
- /** The serials pressed in the device step, in the order the grid drew them. */
312
- function chosenDiagnosticsDevices() {
313
- return [...diagnosticsDeviceList.querySelectorAll('[data-serial]')]
314
- .filter((tile) => tile.getAttribute('aria-pressed') === 'true')
315
- .map((tile) => tile.dataset.serial);
316
- }
317
-
318
- function renderDiagnosticsWizard() {
319
- diagnosticsQuestion.hidden = diagnosticsWizardState.mode !== 'tiles';
320
- diagnosticsDevices.hidden = diagnosticsWizardState.mode !== 'devices';
321
- diagnosticsFrequency.hidden = diagnosticsWizardState.mode !== 'frequency';
322
- diagnosticsMatch.hidden = diagnosticsWizardState.mode !== 'match';
323
- if (diagnosticsWizardState.mode === 'match') {
324
- renderDiagnosticsGuidance(diagnosticsWizardState.profile);
325
- diagnosticsModeSummary.textContent =
326
- messages[
327
- diagnosticsWizardState.reproductionMode === 'intermittent'
328
- ? 'diagnosticsModeIntermittentSummary'
329
- : 'diagnosticsModeNowSummary'
330
- ] ?? '';
331
- }
267
+ function renderDiagnosticsGuidance(profile) {
268
+ const guidance = diagnosticsProfiles[profile] ?? diagnosticsProfiles.other;
269
+ diagnosticsPhaseTitle.textContent = messages[guidance.title] ?? '';
270
+ diagnosticsGuidanceAction.textContent = messages[guidance.action] ?? '';
332
271
  }
333
272
 
334
273
  /**
@@ -340,175 +279,133 @@ function renderDiagnosticsWizard() {
340
279
  */
341
280
  function setIssueStepReachable(url) {
342
281
  diagnosticsIssue.href = url;
343
- diagnosticsIssue.setAttribute('aria-disabled', url ? 'false' : 'true');
344
- diagnosticsIssueHint.textContent = messages[url ? 'diagnosticsIssueOpensTab' : 'diagnosticsIssueNeedsArchive'] ?? '';
282
+ diagnosticsExistingIssue.href = url.replace(/\/issues\/new.*$/, '/issues?q=is%3Aissue+involves%3A%40me');
283
+ }
284
+
285
+ /**
286
+ * Leaves the archive dialog once its file is handed over, which is what ends the session: the plugin keeps it until
287
+ * then so a download that never arrived can be tried again.
288
+ */
289
+ async function endDiagnosticsCase() {
290
+ try {
291
+ renderDiagnostics(await requestWithinDeadline('/diagnostics/cancel', undefined, 12000));
292
+ } catch (error) {
293
+ await redrawDiagnosticsAfterFailure('inactive', error);
294
+ }
295
+ diagnosticsQuestionText.focus?.();
345
296
  }
346
297
 
347
298
  function renderDiagnostics(state) {
348
299
  if (state.supportCaseId !== diagnosticsState.supportCaseId) diagnosticsArchiveDownloaded = false;
349
300
  diagnosticsState = state;
350
- const screen = diagnosticsWizard.screen(state, diagnosticsStartingAnother);
301
+ const screen = diagnosticsScreen(state);
351
302
  const choosing = screen === 'choose';
352
303
  const reviewing = screen === 'review';
353
- const reproductionMode = state.reproductionMode ?? 'now';
354
- const collecting = isDashboardUiReproducing(state);
355
- for (const entry of [mastheadDiagnostics, menuDiagnostics]) {
356
- if (collecting) entry.dataset.collecting = 'true';
357
- else delete entry.dataset.collecting;
358
- entry.setAttribute(
359
- 'aria-label',
360
- messages[collecting ? 'diagnosticsCollectingFinishHere' : 'menuDiagnostics'] ?? '',
361
- );
362
- }
304
+ const nothingRecorded = isNothingRecorded(state);
305
+ renderDiagnosticsEntries();
363
306
  diagnosticsWizardPanel.hidden = !choosing;
364
307
  diagnosticsGuidance.hidden = screen !== 'reproduce';
365
- diagnosticsGuidanceBeforeSection.hidden = state.status === 'reproducing';
366
308
  diagnosticsActions.hidden = screen !== 'reproduce';
367
- diagnosticsReproduction.disabled = !['authorized', 'reproducing'].includes(state.status);
368
- diagnosticsReproduction.textContent =
369
- reproductionMode === 'intermittent'
370
- ? state.status === 'reproducing'
371
- ? messages.diagnosticsIntermittentIssueHappened
372
- : messages.diagnosticsIntermittentStartWaiting
373
- : state.status === 'reproducing'
374
- ? messages.diagnosticsNowFinish
375
- : messages.diagnosticsStartRecording;
376
- diagnosticsIssue.hidden = true;
309
+ diagnosticsReproduction.disabled = state.status !== 'reproducing';
377
310
  setIssueStepReachable(diagnosticsArchiveDownloaded ? (state.issueUrl ?? '') : '');
378
- diagnosticsResult.hidden = !reviewing;
311
+ const offering = (reviewing || diagnosticsArchiveDownloaded) && !diagnosticsPanel.hidden;
312
+ if (offering && !diagnosticsResult.open) diagnosticsResult.showModal?.();
313
+ if (!offering && diagnosticsResult.open) diagnosticsResult.close?.();
314
+ diagnosticsResultHeading.textContent =
315
+ messages[
316
+ diagnosticsArchiveDownloaded
317
+ ? 'diagnosticsArchiveDownloaded'
318
+ : state.reproductionEndedAt && state.reproductionEndedAt === state.expiresAt
319
+ ? 'diagnosticsCaptureTimedOut'
320
+ : 'diagnosticsEvidenceReady'
321
+ ] ?? '';
379
322
  const reviewed = reviewing && diagnosticsReviewedCaseId === (state.supportCaseId ?? '');
380
- diagnosticsManifest.hidden = !reviewed;
381
- diagnosticsReviewConfirmLabel.hidden = !reviewed;
382
- diagnosticsExport.hidden = !reviewed;
383
- diagnosticsIssue.hidden = !reviewed;
323
+ diagnosticsExport.hidden = !reviewed || diagnosticsArchiveDownloaded;
324
+ diagnosticsHandoff.hidden = !diagnosticsArchiveDownloaded;
325
+ diagnosticsHandoffNote.hidden = !diagnosticsArchiveDownloaded;
326
+ if (!offering) diagnosticsResultStatus.textContent = '';
327
+ else if (!diagnosticsArchiveDownloaded && state.missingEvidence?.length) {
328
+ diagnosticsResultStatus.textContent =
329
+ messages[nothingRecorded ? 'diagnosticsNothingRecorded' : 'diagnosticsMissingEvidence'] ?? '';
330
+ }
331
+ diagnosticsDownloadAgain.hidden = !diagnosticsArchiveDownloaded;
384
332
  if (!reviewed) {
385
- diagnosticsReviewConfirm.checked = false;
386
333
  diagnosticsExport.disabled = true;
387
334
  diagnosticsReviewId = '';
388
- if (reviewing) void ensureArchiveReview(state.supportCaseId ?? '');
335
+ if (reviewing && !diagnosticsArchiveDownloaded) void ensureArchiveReview(state.supportCaseId ?? '');
389
336
  }
390
337
  if (screen === 'reproduce') {
391
338
  renderDiagnosticsGuidance(state.profile);
392
- if (reproductionMode === 'intermittent') {
393
- diagnosticsGuidanceBeforeSection.hidden = true;
394
- diagnosticsGuidanceAction.textContent =
395
- messages[
396
- state.status === 'reproducing'
397
- ? 'diagnosticsIntermittentWaitingGuidance'
398
- : 'diagnosticsIntermittentReadyGuidance'
399
- ] ?? '';
400
- }
339
+ const stopsAt = new Date(state.expiresAt).toLocaleString(shell.lang || undefined, {
340
+ weekday: 'long',
341
+ hour: 'numeric',
342
+ minute: '2-digit',
343
+ });
344
+ diagnosticsCaptureNote.textContent = (messages.diagnosticsCaptureKeepsRunning ?? '').replace('{time}', stopsAt);
401
345
  }
402
- if (choosing) renderDiagnosticsWizard();
403
- const statusKey = reviewing
404
- ? state.missingEvidence?.length
405
- ? 'diagnosticsMissingEvidence'
406
- : 'diagnosticsComplete'
407
- : {
408
- inactive: 'diagnosticsInactive',
409
- authorized:
410
- reproductionMode === 'intermittent' ? 'diagnosticsIntermittentAuthorized' : 'diagnosticsAuthorized',
411
- reproducing:
412
- reproductionMode === 'intermittent' ? 'diagnosticsIntermittentReproducing' : 'diagnosticsReproducing',
413
- complete: state.missingEvidence?.length ? 'diagnosticsMissingEvidence' : 'diagnosticsComplete',
414
- expired: 'diagnosticsExpired',
415
- }[state.status];
416
- diagnosticsStatus.textContent = (
417
- choosing && (state.status === 'inactive' || diagnosticsStartingAnother) ? '' : (messages[statusKey] ?? '')
418
- ).replace('{evidence}', state.missingEvidence?.join(', ') ?? '');
346
+ diagnosticsStatus.textContent = '';
347
+ }
348
+
349
+ /** Whether a finished capture holds no record of any log it selected, so its file carries nothing to diagnose. */
350
+ function isNothingRecorded(state) {
351
+ return state.status === 'complete' && state.selectedEvidence?.length > 0 &&
352
+ state.missingEvidence?.length === state.selectedEvidence.length;
419
353
  }
420
354
 
421
355
  /**
422
- * The catalogue entry that says, in a reader's words, what a never-collected class is.
423
- *
424
- * A class with no entry falls back to its own identifier, which is visible rather than absent, so a newly
425
- * declared class shows up as something to translate instead of quietly disappearing from the list.
356
+ * Marks both diagnostics actions while a capture runs, whichever area it is on, and otherwise the dashboard's own
357
+ * while it shows a problem.
426
358
  */
427
- const NEVER_COLLECTED_LABELS = {
428
- 'credentials-and-authentication': 'excludedCredentials',
429
- 'tokens-cookies-and-authorization': 'excludedTokens',
430
- 'session-and-push-stores': 'excludedSessions',
431
- 'private-and-symmetric-keys': 'excludedKeys',
432
- 'unconstrained-internal-objects': 'excludedInternalData',
433
- 'camera-images-talkback-and-raw-media': 'excludedMedia',
434
- };
435
-
436
- function renderArchiveManifest(manifest) {
437
- diagnosticsManifest.replaceChildren();
438
- const summary = document.createElement('p');
439
- const mode =
440
- messages[manifest.reproductionMode === 'intermittent' ? 'diagnosticsModeIntermittent' : 'diagnosticsModeNow'] ??
441
- manifest.reproductionMode;
442
- summary.textContent = `${manifest.archiveFormat} v${manifest.version} · ${manifest.keyId} · ${messages.diagnosticsArchiveMode ?? ''} ${mode} · ${(messages.diagnosticsArchiveExpires ?? '').replace('{expiresAt}', new Date(manifest.archiveExpiresAt).toLocaleString(shell.lang || 'en'))}`;
443
- const evidence = document.createElement('ul');
444
- for (const item of manifest.evidence ?? []) {
445
- const row = document.createElement('li');
446
- const size = item.bytes === undefined ? '' : ` · ${item.bytes} B`;
447
- const truncated = item.truncated ? ` · ${messages.diagnosticsArchiveTruncated ?? ''}` : '';
448
- const missing = item.missingReason ? ` · ${item.missingReason}` : '';
449
- const fields = (item.fields ?? []).map((field) => `${field.field}: ${field.privacyClass}`).join(', ');
450
- row.textContent = `${item.evidence} · ${item.privacyClass} · ${item.status}${missing}${size}${truncated}${fields ? ` · ${messages.diagnosticsArchiveFields ?? ''} ${fields}` : ''}`;
451
- evidence.append(row);
452
- }
453
- const detail = document.createElement('details');
454
- const detailLabel = document.createElement('summary');
455
- detailLabel.textContent = messages.diagnosticsArchiveDetail ?? '';
456
- detail.append(detailLabel, summary, evidence);
457
- const exclusions = document.createElement('p');
458
- const neverCollected = (manifest.excludedClasses ?? []).map(
459
- (excluded) => messages[NEVER_COLLECTED_LABELS[excluded]] ?? excluded,
460
- );
461
- exclusions.textContent = `${messages.diagnosticsArchiveExcluded ?? ''} ${neverCollected.join(', ')}.`;
462
- diagnosticsManifest.append(detail, exclusions);
463
- const uncovered = (manifest.evidence ?? []).filter((item) => item.coversReproduction === false);
464
- if (uncovered.length > 0) {
465
- const gap = document.createElement('p');
466
- gap.textContent = (messages.diagnosticsArchiveCoverageGap ?? '')
467
- .replace('{classes}', uncovered.map((item) => item.evidence).join(', '))
468
- .replace(
469
- '{retainedFrom}',
470
- new Date(
471
- Math.min(...uncovered.map((item) => Date.parse(item.retainedFrom))),
472
- ).toLocaleString(shell.lang || 'en'),
473
- );
474
- diagnosticsManifest.append(gap);
359
+ function renderDiagnosticsEntries() {
360
+ const collecting = diagnosticsState.status === 'reproducing';
361
+ for (const entry of [mastheadDiagnostics, menuDiagnostics]) {
362
+ const attending = !collecting && entry === menuDiagnostics && dashboardAttention !== undefined;
363
+ if (collecting) entry.dataset.collecting = 'true';
364
+ else delete entry.dataset.collecting;
365
+ if (attending) entry.dataset.attention = 'true';
366
+ else delete entry.dataset.attention;
367
+ entry.setAttribute(
368
+ 'aria-label',
369
+ messages[collecting ? 'diagnosticsCollectingFinishHere' : attending ? 'diagnosticsAttention' : 'menuDiagnostics'] ??
370
+ '',
371
+ );
475
372
  }
476
- diagnosticsManifest.hidden = false;
477
373
  }
478
374
 
479
375
  /**
480
- * The manifest for the session on screen, fetched once.
376
+ * The reviewed archive for the session on screen, fetched once.
481
377
  *
482
- * A completed reproduction that the reporter is looking at is a file they came for, so the manifest is
483
- * read without being asked for. It is read once per session rather than per render, because assembling it
484
- * reads every collected log. Exporting spends it, so the session is forgotten again and a second download
485
- * asks for a fresh one.
378
+ * A completed reproduction that the reporter is looking at is a file they came for, so the archive is
379
+ * reviewed without being asked for. It is reviewed once per session rather than per render, because
380
+ * assembling it reads every collected log. Exporting spends it, and ends the session with it.
486
381
  */
487
- async function ensureArchiveReview(caseId) {
488
- if (!caseId || diagnosticsReviewedCaseId === caseId || diagnosticsReviewPending) return;
489
- diagnosticsReviewPending = true;
490
- try {
491
- const review = await requestWithinDeadline('/diagnostics/archive/review', undefined, 12000);
492
- diagnosticsReviewId = review.reviewId;
493
- diagnosticsReviewedCaseId = caseId;
494
- renderArchiveManifest(review.manifest);
495
- diagnosticsReviewConfirmLabel.hidden = false;
496
- diagnosticsExport.hidden = false;
497
- diagnosticsExport.disabled = !diagnosticsReviewConfirm.checked;
498
- diagnosticsIssue.hidden = false;
499
- } catch {
500
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
501
- } finally {
502
- diagnosticsReviewPending = false;
503
- }
382
+ function ensureArchiveReview(caseId) {
383
+ if (!caseId || diagnosticsReviewedCaseId === caseId) return Promise.resolve();
384
+ diagnosticsReviewRequest ??= (async () => {
385
+ try {
386
+ const review = await requestWithinDeadline('/diagnostics/archive/review', undefined, 12000);
387
+ diagnosticsReviewId = review.reviewId;
388
+ diagnosticsReviewedCaseId = caseId;
389
+ diagnosticsExport.hidden = false;
390
+ diagnosticsExport.disabled = false;
391
+ } catch {
392
+ diagnosticsResultStatus.textContent = messages.diagnosticsFailed ?? '';
393
+ } finally {
394
+ diagnosticsReviewRequest = undefined;
395
+ }
396
+ })();
397
+ return diagnosticsReviewRequest;
504
398
  }
505
399
 
506
- diagnosticsReviewConfirm.addEventListener('change', () => {
507
- diagnosticsExport.disabled = !diagnosticsReviewConfirm.checked || !diagnosticsReviewId;
508
- });
509
-
510
- diagnosticsExport.addEventListener('click', async () => {
511
- if (!diagnosticsReviewConfirm.checked || !diagnosticsReviewId) return;
400
+ /**
401
+ * Hands the archive of the session on screen over as a download.
402
+ *
403
+ * Finishing a reproduction calls this without being asked, since the file is what the reporter finished for; the
404
+ * dialog's own button is the way to try again after a failed attempt.
405
+ */
406
+ async function downloadDiagnosticsArchive() {
407
+ await ensureArchiveReview(diagnosticsState.supportCaseId ?? '');
408
+ if (!diagnosticsReviewId) return;
512
409
  diagnosticsExport.disabled = true;
513
410
  try {
514
411
  const exported = await requestWithinDeadline(
@@ -519,138 +416,119 @@ diagnosticsExport.addEventListener('click', async () => {
519
416
  const download = document.createElement('a');
520
417
  download.href = `data:${exported.mediaType};base64,${exported.archive}`;
521
418
  download.download = exported.filename;
419
+ diagnosticsHandoffNote.textContent = (messages.diagnosticsArchiveHandoff ?? '').replace(
420
+ '{filename}',
421
+ exported.filename,
422
+ );
423
+ diagnosticsResultStatus.textContent = '';
522
424
  document.body.appendChild(download);
523
425
  download.click();
524
426
  document.body.removeChild(download);
525
427
  diagnosticsReviewId = '';
526
428
  diagnosticsReviewedCaseId = '';
527
- diagnosticsReviewConfirm.checked = false;
528
429
  diagnosticsArchiveDownloaded = true;
529
- setIssueStepReachable(diagnosticsState.issueUrl ?? '');
430
+ renderDiagnostics(diagnosticsState);
431
+ diagnosticsResultHeading.focus?.();
530
432
  } catch {
531
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
433
+ // The plugin spends a review on any attempt, so trying again asks for a fresh one.
434
+ diagnosticsReviewId = '';
435
+ diagnosticsReviewedCaseId = '';
436
+ diagnosticsExport.disabled = false;
437
+ diagnosticsResultStatus.textContent = messages.diagnosticsFailed ?? '';
532
438
  }
533
- });
439
+ }
534
440
 
535
- diagnosticsAuthorize.addEventListener('click', async () => {
536
- diagnosticsAuthorize.disabled = true;
537
- let focusAuthorizeAfter = false;
538
- const previousStatus = diagnosticsState.status;
539
- try {
540
- const authorized = await requestWithinDeadline(
541
- '/diagnostics/authorize',
542
- {
543
- profile: diagnosticsWizardState.profile,
544
- reproductionMode: diagnosticsWizardState.reproductionMode,
545
- ...(diagnosticsWizardState.devices === undefined ? {} : { affectedDevices: diagnosticsWizardState.devices }),
546
- },
547
- 12000,
548
- );
549
- diagnosticsStartingAnother = false;
550
- renderDiagnostics(authorized);
551
- if (diagnosticsState.status === 'authorized') diagnosticsGuidance.focus?.();
552
- } catch {
553
- try {
554
- const refreshed = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
555
- if (refreshed.status !== previousStatus) diagnosticsStartingAnother = false;
556
- renderDiagnostics(refreshed);
557
- if (refreshed.status === previousStatus) {
558
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
559
- focusAuthorizeAfter = true;
560
- } else if (refreshed.status === 'authorized') diagnosticsGuidance.focus?.();
561
- } catch {
562
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
563
- }
564
- } finally {
565
- diagnosticsAuthorize.disabled = false;
566
- if (focusAuthorizeAfter) diagnosticsAuthorize.focus?.();
441
+ diagnosticsExport.addEventListener('click', downloadDiagnosticsArchive);
442
+ diagnosticsDownloadAgain.addEventListener('click', downloadDiagnosticsArchive);
443
+
444
+ /**
445
+ * Draws the session as the plugin holds it after `error`, and says so unless it reached `expected`, inside the
446
+ * download dialog while that covers the panel. A request that timed out may still land, so its late answer draws
447
+ * the session again.
448
+ */
449
+ async function redrawDiagnosticsAfterFailure(expected, error) {
450
+ const refreshed = await settleDiagnostics().catch(() => undefined);
451
+ if (refreshed?.status !== expected) {
452
+ (diagnosticsResult.open ? diagnosticsResultStatus : diagnosticsStatus).textContent = messages.diagnosticsFailed ?? '';
567
453
  }
568
- });
454
+ error?.late?.then(settleDiagnostics).catch(() => undefined);
455
+ }
569
456
 
570
- for (const tile of diagnosticsTiles) {
571
- tile.addEventListener('click', async () => {
572
- diagnosticsWizardState = diagnosticsWizard.select(diagnosticsWizardState, tile.dataset.diagnosticsTile);
573
- renderDiagnosticsWizard();
574
- if (diagnosticsWizardState.mode === 'devices') await renderDiagnosticsDeviceList();
575
- else diagnosticsFrequencyHeading.focus?.();
576
- });
457
+ /**
458
+ * Draws the session the plugin holds. One opened but never started is what a failed or late pick leaves behind;
459
+ * nothing on screen stands for it and it keeps detailed logging on, so it is cancelled rather than drawn.
460
+ */
461
+ async function settleDiagnostics() {
462
+ let state = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
463
+ if (state.status === 'authorized') state = await requestWithinDeadline('/diagnostics/cancel', undefined, 12000);
464
+ renderDiagnostics(state);
465
+ return state;
577
466
  }
578
467
 
579
- diagnosticsDevicesEvery.addEventListener('click', () => {
580
- const tiles = [...diagnosticsDeviceList.querySelectorAll('[data-serial]')];
581
- for (const tile of tiles) tile.setAttribute('aria-pressed', 'true');
582
- diagnosticsDevicesChosen.disabled = tiles.length === 0;
583
- if (tiles.length === 0) {
584
- diagnosticsWizardState = diagnosticsWizard.chooseDevices(diagnosticsWizardState, 'all');
585
- renderDiagnosticsWizard();
586
- diagnosticsFrequencyHeading.focus?.();
468
+ /**
469
+ * Captures the area picked. The area is the only answer a capture needs, so the pick opens the session and starts
470
+ * it in one go.
471
+ */
472
+ async function startDiagnosticsCapture(profile) {
473
+ for (const tile of diagnosticsTiles) tile.disabled = true;
474
+ try {
475
+ await requestWithinDeadline('/diagnostics/authorize', { profile }, 12000);
476
+ const state = await requestWithinDeadline('/diagnostics/reproduction/start', undefined, 12000);
477
+ renderDiagnostics(state);
478
+ if (isDashboardUiReproducing(state)) {
479
+ await recordActiveUiEvent('background-started').catch(() => undefined);
480
+ closeDashboardPanel();
481
+ return;
482
+ }
483
+ diagnosticsPhaseTitle.focus?.();
484
+ } catch (error) {
485
+ await redrawDiagnosticsAfterFailure('reproducing', error);
486
+ } finally {
487
+ for (const tile of diagnosticsTiles) tile.disabled = false;
587
488
  }
588
- });
589
-
590
- diagnosticsDevicesChosen.addEventListener('click', () => {
591
- diagnosticsWizardState = diagnosticsWizard.chooseDevices(diagnosticsWizardState, chosenDiagnosticsDevices());
592
- renderDiagnosticsWizard();
593
- diagnosticsFrequencyHeading.focus?.();
594
- });
595
-
596
- function chooseDiagnosticsReproductionMode(reproductionMode) {
597
- diagnosticsWizardState = diagnosticsWizard.chooseReproductionMode(diagnosticsWizardState, reproductionMode);
598
- renderDiagnosticsWizard();
599
- diagnosticsGuidanceTitle.focus?.();
600
489
  }
601
490
 
602
- diagnosticsFrequencyNow.addEventListener('click', () => chooseDiagnosticsReproductionMode('now'));
603
- diagnosticsFrequencyIntermittent.addEventListener('click', () => chooseDiagnosticsReproductionMode('intermittent'));
604
-
605
- diagnosticsFrequencyBack.addEventListener('click', () => {
606
- diagnosticsWizardState = diagnosticsWizard.backFromFrequency(diagnosticsWizardState);
607
- renderDiagnosticsWizard();
608
- diagnosticsQuestionText.focus?.();
609
- });
610
-
611
- diagnosticsReject.addEventListener('click', () => {
612
- diagnosticsWizardState = diagnosticsWizard.reject(diagnosticsWizardState);
613
- renderDiagnosticsWizard();
614
- diagnosticsQuestionText.focus?.();
615
- });
491
+ for (const tile of diagnosticsTiles) {
492
+ tile.addEventListener('click', () => startDiagnosticsCapture(tile.dataset.diagnosticsTile));
493
+ }
616
494
 
617
- diagnosticsStartAnother.addEventListener('click', () => {
618
- diagnosticsStartingAnother = true;
619
- diagnosticsWizardState = diagnosticsWizard.start();
620
- renderDiagnostics(diagnosticsState);
621
- diagnosticsQuestionText.focus?.();
495
+ diagnosticsStartAnother.addEventListener('click', endDiagnosticsCase);
496
+ /** Escape leaves the archive dialog only once its file is downloaded, and leaving it finishes the session. */
497
+ diagnosticsResult.addEventListener('cancel', (event) => {
498
+ event.preventDefault();
499
+ if (diagnosticsArchiveDownloaded) endDiagnosticsCase();
622
500
  });
623
501
 
624
502
  diagnosticsReproduction.addEventListener('click', async () => {
625
503
  diagnosticsReproduction.disabled = true;
626
- const previousStatus = diagnosticsState.status;
627
- const path =
628
- diagnosticsState.status === 'reproducing' ? '/diagnostics/reproduction/end' : '/diagnostics/reproduction/start';
629
504
  try {
630
- let state = await requestWithinDeadline(path, undefined, 12000);
505
+ let state = await requestWithinDeadline('/diagnostics/reproduction/end', undefined, 12000);
631
506
  if (state.status === 'complete' && state.missingEvidence?.length) {
632
507
  await new Promise((resolve) => setTimeout(resolve, 250));
633
508
  state = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
634
509
  }
635
510
  renderDiagnostics(state);
636
- if (previousStatus === 'authorized' && isDashboardUiReproducing(state)) {
637
- await recordActiveUiEvent('background-started').catch(() => undefined);
638
- closeDashboardPanel();
639
- return;
640
- }
641
- if (state.status === 'complete' || state.partialExportAvailable) diagnosticsResultHeading.focus?.();
642
- } catch {
643
- try {
644
- const refreshed = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
645
- renderDiagnostics(refreshed);
646
- if (refreshed.status === previousStatus) diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
647
- if (refreshed.status === 'complete' || refreshed.partialExportAvailable) diagnosticsResultHeading.focus?.();
648
- else if (refreshed.status === 'authorized' || refreshed.status === 'reproducing') diagnosticsGuidance.focus?.();
649
- } catch {
650
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
651
- }
511
+ } catch (error) {
512
+ await redrawDiagnosticsAfterFailure('complete', error);
513
+ } finally {
514
+ diagnosticsReproduction.disabled = diagnosticsState.status !== 'reproducing';
515
+ }
516
+ if (diagnosticsState.partialExportAvailable && !isNothingRecorded(diagnosticsState)) {
517
+ diagnosticsResultHeading.focus?.();
518
+ await downloadDiagnosticsArchive();
519
+ }
520
+ });
521
+
522
+ /** Cancelling a capture deletes its session without a file, and puts the areas back on the panel. */
523
+ diagnosticsCancel.addEventListener('click', async () => {
524
+ diagnosticsCancel.disabled = true;
525
+ try {
526
+ renderDiagnostics(await requestWithinDeadline('/diagnostics/cancel', undefined, 12000));
527
+ diagnosticsQuestionText.focus?.();
528
+ } catch (error) {
529
+ await redrawDiagnosticsAfterFailure('inactive', error);
652
530
  } finally {
653
- diagnosticsReproduction.disabled = !['authorized', 'reproducing'].includes(diagnosticsState.status);
531
+ diagnosticsCancel.disabled = false;
654
532
  }
655
533
  });
656
534
 
@@ -678,7 +556,7 @@ function openDashboardPanel(panel, trigger) {
678
556
  dashboardState.hidden = insideDashboard;
679
557
  dashboardSummary.hidden = insideDashboard;
680
558
  deviceGroups.hidden = insideDashboard;
681
- for (const other of [diagnosticsPanel, advancedPanel, devicePanel]) {
559
+ for (const other of [diagnosticsPanel, advancedPanel, devicePanel, wizardPanel]) {
682
560
  other.hidden = other !== panel;
683
561
  }
684
562
  panel.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
@@ -690,6 +568,8 @@ function closeDashboardPanel() {
690
568
  diagnosticsPanel.hidden = true;
691
569
  advancedPanel.hidden = true;
692
570
  devicePanel.hidden = true;
571
+ wizardPanel.hidden = true;
572
+ stopWizardLive();
693
573
  dashboardState.hidden = false;
694
574
  dashboardSummary.hidden = false;
695
575
  deviceGroups.hidden = false;
@@ -707,21 +587,46 @@ function closeDashboardPanel() {
707
587
  }
708
588
  }
709
589
 
710
- mastheadDiagnostics.addEventListener('click', () => menuDiagnostics.click());
711
-
712
- // The dashboard's own offer of a run goes the same way the menu does, so there is one path into the wizard.
713
- dashboardDiagnose.addEventListener('click', () => menuDiagnostics.click());
714
-
715
- menuDiagnostics.addEventListener('click', async () => {
590
+ /**
591
+ * Opens the diagnostics panel. A preset is an area the reporter already chose to diagnose from a problem the
592
+ * dashboard named beside the privacy note, so a panel with nothing under way captures it at once. Opened over the
593
+ * sign-in screen, the panel asks like any other and puts focus on the startup area.
594
+ */
595
+ async function openDiagnostics(preset) {
596
+ const signingIn = !setupContent.hidden;
716
597
  openDashboardPanel(diagnosticsPanel, menuDiagnostics);
717
- // Drawn on opening, because the wizard's first question is only ever put on the page by this call.
718
- renderDiagnosticsWizard();
719
598
  try {
720
599
  renderDiagnostics(await requestWithinDeadline('/diagnostics/status', undefined, 12000));
721
600
  } catch {
722
601
  diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
723
602
  recordActiveUiEventBestEffort('request-failed');
603
+ return;
724
604
  }
605
+ if (diagnosticsScreen(diagnosticsState) !== 'choose') return;
606
+ if (preset) await startDiagnosticsCapture(preset.profile);
607
+ else if (signingIn) {
608
+ diagnosticsTiles.find((tile) => tile.dataset.diagnosticsTile === 'startup-authentication')?.focus?.();
609
+ }
610
+ }
611
+
612
+ mastheadDiagnostics.addEventListener('click', () => openDiagnostics());
613
+
614
+ /** While the dashboard shows a problem and no session is under way, the action names it before opening the wizard. */
615
+ menuDiagnostics.addEventListener('click', () => {
616
+ if (!menuDiagnostics.dataset.attention || diagnosticsScreen(diagnosticsState) !== 'choose') {
617
+ return openDiagnostics();
618
+ }
619
+ attentionTitle.textContent = dashboardAttention.title;
620
+ attentionSummary.textContent = dashboardAttention.summary;
621
+ attentionDialog.showModal?.();
622
+ });
623
+ attentionDiagnose.addEventListener('click', () => {
624
+ attentionDialog.close?.();
625
+ return openDiagnostics(dashboardAttention);
626
+ });
627
+ attentionChoose.addEventListener('click', () => {
628
+ attentionDialog.close?.();
629
+ return openDiagnostics();
725
630
  });
726
631
  menuAdvanced.addEventListener('click', async () => {
727
632
  const config = configuredBlock() ?? {};
@@ -731,6 +636,11 @@ menuAdvanced.addEventListener('click', async () => {
731
636
  warmUpMarked = new Set();
732
637
  renderWarmUp();
733
638
  advancedFfmpeg.value = config.ffmpegPath ?? '';
639
+ advancedSmallVideoPackets.checked = config.smallVideoPackets === true;
640
+ advancedLiveVideoPassthrough.checked = config.liveVideoPassthrough === true;
641
+ advancedHardwareVideoEncoder.checked = config.hardwareVideoEncoder !== false;
642
+ advancedHdLiveVideo.checked = config.hdLiveVideo === true;
643
+ advancedIgnoreHomeBitRate.checked = config.ignoreHomeBitRate === true;
734
644
  openDashboardPanel(advancedPanel, menuAdvanced);
735
645
  /**
736
646
  * The panel asks for its own candidates rather than relying on the devices view having been opened first.
@@ -748,9 +658,8 @@ menuAdvanced.addEventListener('click', async () => {
748
658
  }
749
659
  });
750
660
  diagnosticsClose.addEventListener('click', () => {
751
- diagnosticsStartingAnother = false;
752
- renderDiagnostics(diagnosticsState);
753
661
  closeDashboardPanel();
662
+ renderDiagnostics(diagnosticsState);
754
663
  });
755
664
  advancedClose.addEventListener('click', () => {
756
665
  closeDashboardPanel();
@@ -787,6 +696,16 @@ async function updateAdvancedSettings() {
787
696
  else next.maxConcurrentMediaSessions = maxConcurrentMediaSessions;
788
697
  if (ffmpegPath) next.ffmpegPath = ffmpegPath;
789
698
  else delete next.ffmpegPath;
699
+ if (advancedSmallVideoPackets.checked) next.smallVideoPackets = true;
700
+ else delete next.smallVideoPackets;
701
+ if (advancedLiveVideoPassthrough.checked) next.liveVideoPassthrough = true;
702
+ else delete next.liveVideoPassthrough;
703
+ if (advancedHardwareVideoEncoder.checked) delete next.hardwareVideoEncoder;
704
+ else next.hardwareVideoEncoder = false;
705
+ if (advancedHdLiveVideo.checked) next.hdLiveVideo = true;
706
+ else delete next.hdLiveVideo;
707
+ if (advancedIgnoreHomeBitRate.checked) next.ignoreHomeBitRate = true;
708
+ else delete next.ignoreHomeBitRate;
790
709
  // The default is what the plugin applies when the key is absent, so storing it would only pin today's default.
791
710
  if (warmUpSelection.length === 1 && warmUpSelection[0] === 'doorbellPress') delete next.warmUpEvents;
792
711
  else next.warmUpEvents = [...warmUpSelection];
@@ -802,6 +721,11 @@ async function updateAdvancedSettings() {
802
721
  advancedPolling.addEventListener('change', updateAdvancedSettings);
803
722
  advancedConcurrentMedia.addEventListener('change', updateAdvancedSettings);
804
723
  advancedFfmpeg.addEventListener('change', updateAdvancedSettings);
724
+ advancedSmallVideoPackets.addEventListener('change', updateAdvancedSettings);
725
+ advancedLiveVideoPassthrough.addEventListener('change', updateAdvancedSettings);
726
+ advancedHardwareVideoEncoder.addEventListener('change', updateAdvancedSettings);
727
+ advancedHdLiveVideo.addEventListener('change', updateAdvancedSettings);
728
+ advancedIgnoreHomeBitRate.addEventListener('change', updateAdvancedSettings);
805
729
  warmUpAdd.addEventListener('click', () => moveWarmUp('available'));
806
730
  warmUpAddAll.addEventListener('click', () => moveWarmUp('available', true));
807
731
  warmUpRemove.addEventListener('click', () => moveWarmUp('chosen'));
@@ -920,7 +844,10 @@ function configSignature(config) {
920
844
  async function updateConfig(block) {
921
845
  const nextConfig = pluginConfig.map((candidate) => (candidate.platform === 'HomebridgeEufy' ? block : candidate));
922
846
  if (!nextConfig.includes(block)) nextConfig.push(block);
923
- await homebridge.updatePluginConfig(nextConfig);
847
+ // Homebridge UI merges each block into the one it holds, so a key the block dropped is sent as undefined to clear it.
848
+ const cleared = Object.keys(configuredBlock() ?? {}).filter((key) => !(key in block));
849
+ const sent = { ...Object.fromEntries(cleared.map((key) => [key, undefined])), ...block };
850
+ await homebridge.updatePluginConfig(nextConfig.map((candidate) => (candidate === block ? sent : candidate)));
924
851
  pluginConfig = nextConfig;
925
852
  if (configSignature(nextConfig) === savedConfigSignature) homebridge.disableSaveButton();
926
853
  else homebridge.enableSaveButton();
@@ -933,15 +860,422 @@ dashboardView.bindPreferences(dashboardElements, configuredBlock, updateConfig,
933
860
  openDashboardPanel(devicePanel, trigger);
934
861
  });
935
862
 
936
- dashboardAuthenticate.addEventListener('click', () => {
863
+ /*
864
+ * The setup wizard walks every device once a sign-in has a running plugin to show it: each camera live, the eufy
865
+ * settings the plugin can change, and the HomeKit choices. A sign-in always leaves Homebridge to restart, so it
866
+ * marks the wizard pending in this browser and the next page opened on a ready plugin starts it.
867
+ */
868
+ let dashboardPluginState;
869
+ let wizardSteps = [];
870
+ let wizardIndex = 0;
871
+ let wizardLiveSerial;
872
+ let wizardLivePictures = 0;
873
+ const wizardElements = {
874
+ deviceSettingsTitle: wizardTitle,
875
+ deviceSettingsEyebrow: wizardEyebrow,
876
+ deviceSettingsControls: wizardControls,
877
+ };
878
+ /** The eufy settings the wizard offers, each with the label its control is named by. */
879
+ const EUFY_SETTING_LABELS = {
880
+ motionDetection: 'wizardMotionDetection',
881
+ nightVision: 'wizardNightVision',
882
+ notificationStyle: 'wizardNotificationStyle',
883
+ };
884
+
885
+ /** Whether a plugin is running to stream and change settings: a degraded one still holds its devices. */
886
+ function wizardPluginUp() {
887
+ return dashboardPluginState === 'ready' || dashboardPluginState === 'degraded';
888
+ }
889
+
890
+ /** The browser key marking a sign-in whose devices have not been walked through yet. */
891
+ const WIZARD_PENDING_KEY = 'homebridge-eufy.setup-wizard-pending';
892
+
893
+ function wizardPending(pending) {
894
+ try {
895
+ if (pending === undefined) return localStorage.getItem(WIZARD_PENDING_KEY) === 'pending';
896
+ if (pending) localStorage.setItem(WIZARD_PENDING_KEY, 'pending');
897
+ else localStorage.removeItem(WIZARD_PENDING_KEY);
898
+ } catch {
899
+ return false;
900
+ }
901
+ return pending;
902
+ }
903
+
904
+ /**
905
+ * The devices the wizard walks through, in the map's order so the arrows follow it: each HomeBase then what hangs
906
+ * off it, then the devices that stand alone. Every camera is one, and every other device with a HomeKit choice to
907
+ * make; a device the plugin only diagnoses has nothing to set.
908
+ */
909
+ function setupWizardSteps(devices) {
910
+ const walkable = (devices ?? []).filter(
911
+ (device) => !device.diagnosticOnly && (device.deviceClass === 'camera' || device.preferences?.length > 0),
912
+ );
913
+ const { bases, standalone } = setupWizardTopology(walkable, devices);
914
+ return [
915
+ ...bases.flatMap(({ base, members }) => [...(base && walkable.includes(base) ? [base] : []), ...members]),
916
+ ...standalone,
917
+ ];
918
+ }
919
+
920
+ /**
921
+ * The household as the map draws it: each HomeBase with the devices that hang off it, then the devices that are
922
+ * their own station. A base is named by its own entry where the dashboard lists one; a device whose base it does
923
+ * not list stays with that base all the same, under no name.
924
+ */
925
+ function setupWizardTopology(steps, devices) {
926
+ const bases = new Map();
927
+ const standalone = [];
928
+ for (const device of devices ?? []) {
929
+ if (device.deviceClass === 'homebase') bases.set(device.serial, { base: device, members: [] });
930
+ }
931
+ for (const device of steps) {
932
+ if (device.deviceClass === 'homebase') continue;
933
+ if (!device.station) {
934
+ standalone.push(device);
935
+ continue;
936
+ }
937
+ if (!bases.has(device.station)) bases.set(device.station, { base: undefined, members: [] });
938
+ bases.get(device.station).members.push(device);
939
+ }
940
+ return { bases: [...bases.values()], standalone };
941
+ }
942
+
943
+ async function openSetupWizard(trigger) {
944
+ wizardSteps = setupWizardSteps(dashboardDevices);
945
+ wizardIndex = 0;
946
+ openDashboardPanel(wizardPanel, trigger);
947
+ await renderWizardStep();
948
+ }
949
+
950
+ /**
951
+ * The advanced form's live video settings by name. A camera may keep its own value of those in
952
+ * {@link WIZARD_LIVE_CAMERA}; the others are general, so the card's toggle for one is the advanced form's own.
953
+ */
954
+ const wizardLiveSettings = {
955
+ liveVideoPassthrough: advancedLiveVideoPassthrough,
956
+ ignoreHomeBitRate: advancedIgnoreHomeBitRate,
957
+ hardwareVideoEncoder: advancedHardwareVideoEncoder,
958
+ smallVideoPackets: advancedSmallVideoPackets,
959
+ };
960
+ const WIZARD_LIVE_CAMERA = ['liveVideoPassthrough', 'ignoreHomeBitRate'];
961
+
962
+ /** The ask and settings the preview's HomeKit side is coded with, as its controls stand. Never saved. */
963
+ function wizardLiveAdaptation() {
964
+ const fields = wizardLiveControls.elements;
965
+ const settings = Object.keys(wizardLiveSettings).map((name) => [name, fields[name].checked]);
966
+ return { ask: fields.ask.value, ...Object.fromEntries(settings) };
967
+ }
968
+
969
+ function stopWizardLive() {
970
+ wizardLiveHomeKitSpinner.hidden = true;
971
+ wizardLiveEncoder.textContent = messages.wizardLiveHomeKit ?? '';
972
+ wizardLiveDiagnose.hidden = true;
973
+ if (!wizardLiveSerial) return;
974
+ wizardLiveSerial = undefined;
975
+ wizardLivePictures = 0;
976
+ wizardLivePlay.hidden = false;
977
+ wizardLiveStop.hidden = true;
978
+ wizardLiveSpinner.hidden = true;
979
+ wizardLiveStatus.textContent = '';
980
+ void requestWithinDeadline('/device/live/stop', {}, 12000).catch(() => undefined);
981
+ }
982
+
983
+ /**
984
+ * Shows a camera's box with its last picture behind the play button, so the step costs nothing until asked.
985
+ * A camera with no picture yet shows the empty box.
986
+ */
987
+ async function showWizardCamera(serial, enabled) {
988
+ wizardLive.hidden = false;
989
+ wizardLiveImage.hidden = true;
990
+ const own = configuredBlock()?.entityPreferences?.[serial] ?? {};
991
+ for (const [name, input] of Object.entries(wizardLiveSettings)) {
992
+ wizardLiveControls.elements[name].checked = own[name] ?? input.checked;
993
+ }
994
+ // A camera switched off in the eufy app has nothing to stream, so it offers no play, only the reason.
995
+ wizardLivePlay.hidden = enabled === false;
996
+ wizardLiveStatus.textContent = enabled === false ? (messages.deviceStatusSwitchedOff ?? '') : '';
997
+ let answer;
998
+ try {
999
+ answer = await requestWithinDeadline('/device/image', { serial }, 12000);
1000
+ } catch {
1001
+ answer = undefined;
1002
+ }
1003
+ if (!answer?.image || wizardSteps[wizardIndex]?.serial !== serial || wizardLiveSerial) return;
1004
+ wizardLiveImage.src = `data:image/jpeg;base64,${answer.image}`;
1005
+ wizardLiveImage.hidden = false;
1006
+ }
1007
+
1008
+ function startWizardLive(serial) {
1009
+ stopWizardLive();
1010
+ wizardLiveSerial = serial;
1011
+ wizardLivePlay.hidden = true;
1012
+ wizardLiveSpinner.hidden = false;
1013
+ wizardLiveStatus.textContent = messages.wizardLiveStarting ?? '';
1014
+ const body = { serial, adaptation: wizardLiveAdaptation() };
1015
+ void requestWithinDeadline('/device/live/start', body, 12000).catch(() => endWizardLive(serial));
1016
+ }
1017
+
1018
+ /** Ends the stream on screen, saying why HomeKit got no picture: coded video that decoded to nothing, or no video. */
1019
+ function endWizardLive(serial, unplayable) {
1020
+ if (serial !== wizardLiveSerial) return;
1021
+ const failure = unplayable ? messages.wizardLiveUnplayable : wizardLivePictures ? '' : messages.wizardLiveFailed;
1022
+ wizardLiveSerial = undefined;
1023
+ wizardLivePictures = 0;
1024
+ wizardLiveStatus.textContent = failure ?? '';
1025
+ wizardLiveDiagnose.hidden = !failure;
1026
+ wizardLivePlay.hidden = false;
1027
+ wizardLiveStop.hidden = true;
1028
+ wizardLiveSpinner.hidden = true;
1029
+ wizardLiveHomeKitSpinner.hidden = true;
1030
+ }
1031
+
1032
+ homebridge.addEventListener('device-live', (event) => {
1033
+ if (!event.data || event.data.serial !== wizardLiveSerial) return;
1034
+ wizardLivePictures += 1;
1035
+ // The stop is offered once there is video to stop; until then the spinner says it is on its way.
1036
+ wizardLiveSpinner.hidden = true;
1037
+ wizardLiveStop.hidden = false;
1038
+ const homekit = event.data.side === 'homekit';
1039
+ // The first picture clears the connecting text; after that only the HomeKit side changes what the status says.
1040
+ if (homekit || wizardLivePictures === 1) wizardLiveStatus.textContent = '';
1041
+ if (homekit) wizardLiveHomeKitSpinner.hidden = true;
1042
+ // An empty HomeKit picture is a run that coded nothing watchable while the camera plays on.
1043
+ if (homekit && !event.data.image) {
1044
+ wizardLiveHomeKit.hidden = true;
1045
+ wizardLiveStatus.textContent = messages.wizardLiveUnplayable ?? '';
1046
+ wizardLiveDiagnose.hidden = false;
1047
+ return;
1048
+ }
1049
+ const image = homekit ? wizardLiveHomeKit : wizardLiveImage;
1050
+ image.src = `data:image/jpeg;base64,${event.data.image}`;
1051
+ image.hidden = false;
1052
+ if (!homekit || typeof event.data.kbps !== 'number') return;
1053
+ // The rate is the encoder's own output, the video HomeKit would be sent, not these pictures.
1054
+ const encoder = event.data.encoder === 'copy' ? messages.wizardLiveCamera : event.data.encoder;
1055
+ wizardLiveEncoder.textContent = (messages.wizardLiveEncoder ?? '')
1056
+ .replace('{encoder}', encoder ?? '')
1057
+ .replace('{kbps}', String(event.data.kbps));
1058
+ });
1059
+ // Only the HomeKit side restarts: its half waits under a spinner while the camera plays on.
1060
+ wizardLiveControls.addEventListener('change', (event) => {
1061
+ const name = event.target?.name;
1062
+ if (wizardLiveSettings[name] && !WIZARD_LIVE_CAMERA.includes(name)) {
1063
+ wizardLiveSettings[name].checked = event.target.checked;
1064
+ void updateAdvancedSettings();
1065
+ }
1066
+ if (!wizardLiveSerial) return;
1067
+ wizardLiveHomeKit.hidden = false;
1068
+ wizardLiveHomeKitSpinner.hidden = false;
1069
+ wizardLiveStatus.textContent = '';
1070
+ wizardLiveDiagnose.hidden = true;
1071
+ const body = { serial: wizardLiveSerial, adaptation: wizardLiveAdaptation() };
1072
+ void requestWithinDeadline('/device/live/adapt', body, 12000).catch(() => undefined);
1073
+ });
1074
+ // Keeps this camera's own ticks, as its preference wherever they differ from the general setting.
1075
+ wizardLiveSave.addEventListener('click', async () => {
1076
+ const serial = wizardSteps[wizardIndex]?.serial;
1077
+ const existing = configuredBlock();
1078
+ if (!serial || !existing) return;
1079
+ const entityPreferences = { ...(existing.entityPreferences ?? {}) };
1080
+ const preference = { ...(entityPreferences[serial] ?? {}) };
1081
+ for (const name of WIZARD_LIVE_CAMERA) {
1082
+ const checked = wizardLiveControls.elements[name].checked;
1083
+ if (checked === wizardLiveSettings[name].checked) delete preference[name];
1084
+ else preference[name] = checked;
1085
+ }
1086
+ if (Object.keys(preference).length === 0) delete entityPreferences[serial];
1087
+ else entityPreferences[serial] = preference;
1088
+ try {
1089
+ await updateConfig({ ...existing, entityPreferences });
1090
+ } catch {
1091
+ wizardLiveStatus.textContent = messages.preferenceSaveFailed ?? '';
1092
+ }
1093
+ });
1094
+ wizardLiveDiagnoseStart.addEventListener('click', () => openDiagnostics({ profile: 'live-media' }));
1095
+ homebridge.addEventListener('device-live-end', (event) => endWizardLive(event.data?.serial, event.data?.unplayable));
1096
+
1097
+ /** One control per eufy setting the device states and can change, named and valued the way the camera describes it. */
1098
+ function eufySettingControl(setting) {
1099
+ const label = messages[EUFY_SETTING_LABELS[setting.setting]] ?? setting.setting;
1100
+ const wrapper = document.createElement('label');
1101
+ wrapper.className = typeof setting.value === 'boolean' ? 'toggle' : 'wizard-setting';
1102
+ wrapper.dataset.setting = '';
1103
+ const name = document.createElement('span');
1104
+ name.className = 'setting-label';
1105
+ name.textContent = label;
1106
+ let control;
1107
+ if (typeof setting.value === 'boolean') {
1108
+ control = document.createElement('input');
1109
+ control.type = 'checkbox';
1110
+ control.checked = setting.value;
1111
+ wrapper.append(control, name);
1112
+ } else {
1113
+ control = document.createElement('select');
1114
+ for (const [value, text] of Object.entries(setting.labels ?? { [setting.value]: String(setting.value) })) {
1115
+ const option = document.createElement('option');
1116
+ option.value = value;
1117
+ option.textContent = text;
1118
+ option.selected = Number(value) === setting.value;
1119
+ control.append(option);
1120
+ }
1121
+ wrapper.append(name, control);
1122
+ }
1123
+ control.dataset.eufySetting = setting.setting;
1124
+ return wrapper;
1125
+ }
1126
+
1127
+ async function renderWizardSettings(serial) {
1128
+ let answer;
1129
+ try {
1130
+ answer = await requestWithinDeadline('/device/settings', { serial }, 12000);
1131
+ } catch {
1132
+ answer = undefined;
1133
+ }
1134
+ if (wizardSteps[wizardIndex]?.serial !== serial) return;
1135
+ const writable = (answer?.settings ?? []).filter((setting) => setting.writable);
1136
+ if (writable.length === 0) return;
1137
+ const status = document.createElement('p');
1138
+ status.className = 'preference-note';
1139
+ status.setAttribute('role', 'status');
1140
+ status.dataset.wizardSettingsStatus = '';
1141
+ wizardSettings.replaceChildren(...writable.map(eufySettingControl), status);
1142
+ wizardEufy.hidden = false;
1143
+ }
1144
+
1145
+ /** One device on the map: a button that opens its step, marked as the current one where it is. */
1146
+ function wizardMapEntry(device, current) {
1147
+ const entry = document.createElement('button');
1148
+ entry.type = 'button';
1149
+ entry.className = 'wizard-map-device';
1150
+ entry.textContent = device.name;
1151
+ entry.dataset.serial = device.serial;
1152
+ if (device.serial === current) entry.setAttribute('aria-current', 'step');
1153
+ return entry;
1154
+ }
1155
+
1156
+ /**
1157
+ * Draws the household: each HomeBase with the devices that hang off it, then the cameras that stand alone. A base
1158
+ * is a step too where it has HomeKit choices, so its name opens it like any other device.
1159
+ */
1160
+ function renderWizardMap(current) {
1161
+ const { bases, standalone } = setupWizardTopology(wizardSteps, dashboardDevices);
1162
+ const group = (title, members, base) => {
1163
+ const section = document.createElement('div');
1164
+ section.className = 'wizard-map-group';
1165
+ const heading = base && wizardSteps.includes(base) ? wizardMapEntry(base, current) : document.createElement('p');
1166
+ heading.classList?.add('wizard-map-base');
1167
+ heading.textContent = title;
1168
+ const list = document.createElement('div');
1169
+ list.className = 'wizard-map-members';
1170
+ list.append(...members.map((device) => wizardMapEntry(device, current)));
1171
+ section.append(heading, list);
1172
+ return section;
1173
+ };
1174
+ wizardMap.replaceChildren(
1175
+ ...bases.map(({ base, members }) => group(base?.name ?? messages.wizardMapBase ?? '', members, base)),
1176
+ ...(standalone.length > 0 ? [group(messages.wizardMapStandalone ?? '', standalone)] : []),
1177
+ );
1178
+ }
1179
+
1180
+ wizardSettings.addEventListener('change', async (event) => {
1181
+ const control = event.target;
1182
+ const setting = control?.dataset?.eufySetting;
1183
+ const device = wizardSteps[wizardIndex];
1184
+ if (!setting || !device) return;
1185
+ const value = control.type === 'checkbox' ? control.checked : Number(control.value);
1186
+ const status = wizardSettings.querySelector('[data-wizard-settings-status]');
1187
+ control.disabled = true;
1188
+ status.textContent = messages.wizardSettingSending ?? '';
1189
+ try {
1190
+ const answer = await requestWithinDeadline('/device/settings/write', { serial: device.serial, setting, value }, 30000);
1191
+ // A write that reached the camera is not yet a camera that changed, so the step says sent rather than done.
1192
+ status.textContent = answer?.ok ? (messages.wizardSettingSent ?? '') : (messages.wizardSettingFailed ?? '');
1193
+ } catch {
1194
+ status.textContent = messages.wizardSettingFailed ?? '';
1195
+ } finally {
1196
+ control.disabled = false;
1197
+ }
1198
+ });
1199
+
1200
+ async function renderWizardStep() {
1201
+ stopWizardLive();
1202
+ wizardLiveHomeKit.hidden = true;
1203
+ wizardSettings.replaceChildren();
1204
+ wizardControls.replaceChildren();
1205
+ wizardEufy.hidden = true;
1206
+ const restart = !wizardPluginUp();
1207
+ const empty = restart || wizardSteps.length === 0;
1208
+ wizardRestart.hidden = !restart;
1209
+ wizardMap.hidden = empty;
1210
+ wizardStage.hidden = empty;
1211
+ wizardHomeKit.hidden = empty;
1212
+ wizardFinish.textContent = restart ? (messages.wizardClose ?? '') : (messages.wizardFinish ?? '');
1213
+ if (empty) {
1214
+ wizardProgress.textContent = '';
1215
+ wizardTitle.textContent = messages.wizardTitle ?? '';
1216
+ wizardEyebrow.textContent = '';
1217
+ return;
1218
+ }
1219
+ const device = wizardSteps[wizardIndex];
1220
+ renderWizardMap(device.serial);
1221
+ dashboardView.renderDeviceSettings(device, configuredBlock() ?? {}, messages, wizardElements);
1222
+ wizardHomeKit.hidden = !(device.preferences?.length > 0);
1223
+ wizardProgress.textContent = (messages.wizardProgress ?? '')
1224
+ .replace('{current}', String(wizardIndex + 1))
1225
+ .replace('{total}', String(wizardSteps.length));
1226
+ // Only a camera has a picture to show; any other device's step is its settings alone.
1227
+ wizardLive.hidden = device.deviceClass !== 'camera';
1228
+ wizardLiveControls.hidden = wizardLive.hidden;
1229
+ if (!wizardLive.hidden) void showWizardCamera(device.serial, device.enabled);
1230
+ await renderWizardSettings(device.serial);
1231
+ }
1232
+
1233
+ function goToWizardStep(index) {
1234
+ if (index < 0 || index >= wizardSteps.length || index === wizardIndex) return;
1235
+ wizardIndex = index;
1236
+ void renderWizardStep();
1237
+ }
1238
+
1239
+ menuWizard.addEventListener('click', () => openSetupWizard(menuWizard));
1240
+ wizardClose.addEventListener('click', () => closeDashboardPanel());
1241
+ wizardLiveStop.addEventListener('click', () => stopWizardLive());
1242
+ wizardLivePlay.addEventListener('click', () => {
1243
+ const device = wizardSteps[wizardIndex];
1244
+ if (device) startWizardLive(device.serial);
1245
+ });
1246
+ wizardMap.addEventListener('click', (event) => {
1247
+ const serial = event.target?.closest?.('[data-serial]')?.dataset?.serial;
1248
+ goToWizardStep(wizardSteps.findIndex((device) => device.serial === serial));
1249
+ });
1250
+ // Closing a wizard that waits for a restart keeps it due; finishing one that walked the devices does not.
1251
+ wizardFinish.addEventListener('click', () => {
1252
+ if (wizardPluginUp()) wizardPending(false);
1253
+ closeDashboardPanel();
1254
+ });
1255
+ window.addEventListener?.('pagehide', () => stopWizardLive());
1256
+
1257
+ function openAuthentication() {
937
1258
  diagnosticsPanel.hidden = true;
938
1259
  advancedPanel.hidden = true;
1260
+ devicePanel.hidden = true;
1261
+ wizardPanel.hidden = true;
1262
+ stopWizardLive();
939
1263
  dashboard.hidden = true;
940
1264
  setupContent.hidden = false;
941
1265
  masthead.hidden = false;
942
1266
  pageTitle.textContent = messages.pageTitle;
943
1267
  accountInput.focus?.();
944
1268
  recordActiveUiEventBestEffort('authentication-opened');
1269
+ }
1270
+
1271
+ dashboardAuthenticate.addEventListener('click', openAuthentication);
1272
+
1273
+ // A saved session the plugin can no longer use stops every device, so nothing on the page works until the user
1274
+ // signs in again. The dialog says so in front of everything else, and Escape does not dismiss it.
1275
+ reauthDialog.addEventListener('cancel', (event) => event.preventDefault());
1276
+ reauthAction.addEventListener('click', () => {
1277
+ reauthDialog.close?.();
1278
+ openAuthentication();
945
1279
  });
946
1280
 
947
1281
  legacyAcknowledge.addEventListener('click', async () => {
@@ -951,12 +1285,19 @@ legacyAcknowledge.addEventListener('click', async () => {
951
1285
  try {
952
1286
  await updateConfig({ ...existing, discardedV4Settings: legacyNames, discardedV4Acknowledged: true });
953
1287
  } catch {
1288
+ legacyStatus.textContent = messages.preferenceSaveFailed ?? '';
954
1289
  return;
955
1290
  }
956
1291
  }
957
1292
  legacyNotice.hidden = true;
958
1293
  });
959
1294
 
1295
+ /**
1296
+ * Writes the signed-in account into the Homebridge configuration, reporting whether it was saved.
1297
+ *
1298
+ * The Save button is enabled before the attempt and never disabled by it, so a write that fails leaves the user
1299
+ * the one control that retries it.
1300
+ */
960
1301
  async function saveAuthenticatedConfig() {
961
1302
  homebridge.enableSaveButton();
962
1303
  try {
@@ -965,7 +1306,9 @@ async function saveAuthenticatedConfig() {
965
1306
  pluginConfig = [pendingConfig];
966
1307
  savedConfigSignature = configSignature(pluginConfig);
967
1308
  homebridge.enableSaveButton();
1309
+ return true;
968
1310
  } catch {
1311
+ return false;
969
1312
  } finally {
970
1313
  passwordInput.value = '';
971
1314
  pendingConfig = undefined;
@@ -985,18 +1328,36 @@ async function showDashboard() {
985
1328
  warmUpCandidates = Array.isArray(snapshot.warmUpCandidates) ? snapshot.warmUpCandidates : [];
986
1329
  // Kept beside the render that drew them, so opening a tile finds the device that tile stands for.
987
1330
  dashboardDevices = snapshot.devices ?? [];
988
- dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
1331
+ dashboardPluginState = snapshot.state;
1332
+ dashboardAttention = dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
1333
+ if (snapshot.state === 'authentication-required' && snapshot.devices?.length > 0 && !reauthDialog.open) {
1334
+ reauthDialog.showModal?.();
1335
+ }
989
1336
  // The image pass runs beside the rendered dashboard, so its failure is dropped here rather than escaping.
990
1337
  void dashboardView
991
1338
  .applyDeviceImages(dashboardElements, (serial) => requestWithinDeadline('/device/image', { serial }, 12000))
992
1339
  .catch(() => undefined);
993
1340
  } catch {
994
- dashboardView.render({ state: 'missing', devices: [] }, configuredBlock() ?? {}, messages, dashboardElements);
1341
+ dashboardAttention = dashboardView.render(
1342
+ { state: 'unreachable', devices: [] },
1343
+ configuredBlock() ?? {},
1344
+ messages,
1345
+ dashboardElements,
1346
+ );
995
1347
  recordActiveUiEventBestEffort('request-failed');
996
1348
  }
1349
+ renderDiagnosticsEntries();
997
1350
  recordActiveUiEventBestEffort('dashboard-opened');
998
1351
  }
999
1352
 
1353
+ /** The message each unsuccessful sign-in outcome is stated with; any other outcome is a failed sign-in. */
1354
+ const AUTH_OUTCOMES = {
1355
+ blocked: 'dashboardOwnerConflictSummary',
1356
+ 'plugin-running': 'authPluginRunning',
1357
+ 'commit-failed': 'authCommitFailed',
1358
+ 'timed-out': 'authTimedOut',
1359
+ };
1360
+
1000
1361
  async function handleResult(result) {
1001
1362
  if (result.status === 'captcha') {
1002
1363
  challenge = 'captcha';
@@ -1005,7 +1366,7 @@ async function handleResult(result) {
1005
1366
  challengeImage.hidden = false;
1006
1367
  challengeLabel.textContent = messages.captchaLabel ?? '';
1007
1368
  challengeForm.hidden = false;
1008
- authStatus.textContent = '';
1369
+ authStatus.textContent = result.retry ? (messages.captchaRetry ?? '') : '';
1009
1370
  return;
1010
1371
  }
1011
1372
  if (result.status === 'two-factor') {
@@ -1014,7 +1375,7 @@ async function handleResult(result) {
1014
1375
  challengeImage.hidden = true;
1015
1376
  challengeLabel.textContent = messages.twoFactorLabel ?? '';
1016
1377
  challengeForm.hidden = false;
1017
- authStatus.textContent = result.method;
1378
+ authStatus.textContent = messages.twoFactorSent ?? '';
1018
1379
  return;
1019
1380
  }
1020
1381
 
@@ -1022,38 +1383,30 @@ async function handleResult(result) {
1022
1383
  if (result.status === 'restart-required') {
1023
1384
  authForm.hidden = true;
1024
1385
  authStatus.textContent = messages.authSuccess ?? '';
1025
- await saveAuthenticatedConfig();
1026
1386
  // The devices this sign-in discovered are already recorded, so the flow ends on them rather than on a notice.
1027
- await showDashboard();
1028
- } else if (result.status === 'blocked' || result.status === 'plugin-running') {
1029
- authForm.hidden = false;
1030
- authStatus.textContent = messages.authBlocked ?? '';
1031
- } else if (result.status === 'timed-out') {
1032
- authForm.hidden = false;
1033
- authStatus.textContent = messages.authTimedOut ?? '';
1387
+ if (await saveAuthenticatedConfig()) {
1388
+ await showDashboard();
1389
+ wizardPending(true);
1390
+ await openSetupWizard();
1391
+ } else authStatus.textContent = messages.authSaveFailed ?? '';
1034
1392
  } else {
1035
1393
  authForm.hidden = false;
1036
- authStatus.textContent = messages.authFailed ?? '';
1394
+ authStatus.textContent = messages[AUTH_OUTCOMES[result.status] ?? 'authFailed'] ?? '';
1037
1395
  }
1038
1396
  }
1039
1397
 
1398
+ /** A sign-in request that did not come back: the page's own deadline elapsing is said to be a timeout. */
1399
+ function reportAuthRequestFailure(error) {
1400
+ authStatus.textContent = messages[error?.message === REQUEST_TIMED_OUT ? 'authTimedOut' : 'authFailed'] ?? '';
1401
+ recordActiveUiEventBestEffort('request-failed');
1402
+ }
1403
+
1040
1404
  authForm.addEventListener('submit', async (event) => {
1041
1405
  event.preventDefault();
1406
+ // A sign-in replaces the account and nothing else: every other key of the block, the child bridge included, is kept.
1042
1407
  const existing = pluginConfig.find((block) => block.platform === 'HomebridgeEufy') ?? {};
1043
- const retained = Object.fromEntries(
1044
- [
1045
- 'pollingIntervalMinutes',
1046
- 'sessionWarmUp',
1047
- 'ffmpegPath',
1048
- 'entityPreferences',
1049
- 'discardedV4Settings',
1050
- 'discardedV4Acknowledged',
1051
- ]
1052
- .filter((key) => Object.hasOwn(existing, key))
1053
- .map((key) => [key, existing[key]]),
1054
- );
1055
1408
  pendingConfig = {
1056
- ...retained,
1409
+ ...existing,
1057
1410
  platform: 'HomebridgeEufy',
1058
1411
  username: accountInput.value.trim(),
1059
1412
  password: passwordInput.value,
@@ -1065,9 +1418,8 @@ authForm.addEventListener('submit', async (event) => {
1065
1418
  setBusy(true);
1066
1419
  try {
1067
1420
  await handleResult(await requestWithinDeadline('/auth/start', { configuration: pendingConfig }));
1068
- } catch {
1069
- authStatus.textContent = messages.authFailed ?? '';
1070
- recordActiveUiEventBestEffort('request-failed');
1421
+ } catch (error) {
1422
+ reportAuthRequestFailure(error);
1071
1423
  } finally {
1072
1424
  setBusy(false);
1073
1425
  }
@@ -1081,9 +1433,8 @@ challengeForm.addEventListener('submit', async (event) => {
1081
1433
  setBusy(true);
1082
1434
  try {
1083
1435
  await handleResult(await requestWithinDeadline(path, body));
1084
- } catch {
1085
- authStatus.textContent = messages.authFailed ?? '';
1086
- recordActiveUiEventBestEffort('request-failed');
1436
+ } catch (error) {
1437
+ reportAuthRequestFailure(error);
1087
1438
  } finally {
1088
1439
  setBusy(false);
1089
1440
  }
@@ -1117,6 +1468,40 @@ if (updateRestart) {
1117
1468
  });
1118
1469
  }
1119
1470
 
1471
+ /** The V4 settings a legacy block sets to something other than its default, which V5 no longer reads. */
1472
+ function legacySettingNames(block) {
1473
+ const defaults = { CameraMaxLivestreamDuration: 30, hkHome: 1, hkAway: 0, hkNight: 3, hkOff: 63 };
1474
+ const effectful = (key, value) => {
1475
+ if (Object.hasOwn(defaults, key)) return value !== defaults[key];
1476
+ if (Array.isArray(value)) return value.length > 0;
1477
+ if (value && typeof value === 'object') return Object.keys(value).length > 0;
1478
+ return Boolean(value);
1479
+ };
1480
+ return [
1481
+ 'CameraMaxLivestreamDuration',
1482
+ 'autoSyncStation',
1483
+ 'cameras',
1484
+ 'cleanCache',
1485
+ 'debugLivestream',
1486
+ 'enableDetailedLogging',
1487
+ 'enableEmbeddedPKCS1Support',
1488
+ 'experimentalMode',
1489
+ 'hkAway',
1490
+ 'hkHome',
1491
+ 'hkNight',
1492
+ 'hkOff',
1493
+ 'ignoreDevices',
1494
+ 'ignoreMultipleDevicesWarning',
1495
+ 'ignoreStations',
1496
+ 'omitLogFiles',
1497
+ 'stations',
1498
+ 'syncStationModes',
1499
+ 'useEmbeddedPKCS1Support',
1500
+ ]
1501
+ .filter((key) => Object.hasOwn(block, key) && effectful(key, block[key]))
1502
+ .sort();
1503
+ }
1504
+
1120
1505
  homebridge.addEventListener('ready', async () => {
1121
1506
  homebridge.disableSaveButton();
1122
1507
  shell.dataset.theme = await homebridge.userCurrentLightingMode();
@@ -1140,7 +1525,7 @@ homebridge.addEventListener('ready', async () => {
1140
1525
  } else {
1141
1526
  const legacyBlock = pluginConfig.find((block) => block.platform === 'EufySecurity');
1142
1527
  if (legacyBlock) {
1143
- legacyNames = legacySettingsView.names(legacyBlock);
1528
+ legacyNames = legacySettingNames(legacyBlock);
1144
1529
  }
1145
1530
  }
1146
1531
  legacyNotice.hidden = legacyNames.length === 0 || legacyAcknowledged;
@@ -1156,5 +1541,6 @@ homebridge.addEventListener('ready', async () => {
1156
1541
  trustedDeviceInput.value = configured?.trustedDeviceName ?? 'Homebridge Eufy';
1157
1542
  if (configured) {
1158
1543
  await showDashboard();
1544
+ if (wizardPending() && wizardPluginUp()) await openSetupWizard();
1159
1545
  }
1160
1546
  });