@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.2 → 5.0.0-beta.20

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 (51) hide show
  1. package/config.schema.json +24 -0
  2. package/dist/account/temporary-authentication.js +3 -1
  3. package/dist/account/temporary-authentication.js.map +1 -1
  4. package/dist/configuration.js +36 -2
  5. package/dist/configuration.js.map +1 -1
  6. package/dist/device/member-evidence.js +10 -3
  7. package/dist/device/member-evidence.js.map +1 -1
  8. package/dist/diagnostics.js +582 -83
  9. package/dist/diagnostics.js.map +1 -1
  10. package/dist/homekit/adapters/camera-streaming.js +54 -26
  11. package/dist/homekit/adapters/camera-streaming.js.map +1 -1
  12. package/dist/homekit/adapters/lock.js +36 -13
  13. package/dist/homekit/adapters/lock.js.map +1 -1
  14. package/dist/homekit/adapters/motion.js +1 -1
  15. package/dist/homekit/adapters/motion.js.map +1 -1
  16. package/dist/homekit/adapters/package-sensor.js +63 -0
  17. package/dist/homekit/adapters/package-sensor.js.map +1 -0
  18. package/dist/homekit/adapters/registry.js +2 -0
  19. package/dist/homekit/adapters/registry.js.map +1 -1
  20. package/dist/homekit/adapters/security-system.js +95 -44
  21. package/dist/homekit/adapters/security-system.js.map +1 -1
  22. package/dist/homekit/coverage-matrix.js +1 -3
  23. package/dist/homekit/coverage-matrix.js.map +1 -1
  24. package/dist/homekit/reconciler.js +6 -2
  25. package/dist/homekit/reconciler.js.map +1 -1
  26. package/dist/media/live-stream.js +98 -54
  27. package/dist/media/live-stream.js.map +1 -1
  28. package/dist/media/recording.js +7 -5
  29. package/dist/media/recording.js.map +1 -1
  30. package/dist/media/snapshot.js +4 -4
  31. package/dist/media/station-live-sessions.js +47 -43
  32. package/dist/media/station-live-sessions.js.map +1 -1
  33. package/dist/platform.js +25 -5
  34. package/dist/platform.js.map +1 -1
  35. package/dist/runtime/channel.js +6 -2
  36. package/dist/runtime/channel.js.map +1 -1
  37. package/dist/runtime/owner.js +3 -0
  38. package/dist/runtime/owner.js.map +1 -1
  39. package/dist/ui/dashboard.js +32 -14
  40. package/dist/ui/dashboard.js.map +1 -1
  41. package/dist/ui/server.js +27 -4
  42. package/dist/ui/server.js.map +1 -1
  43. package/homebridge-ui/public/app.css +353 -88
  44. package/homebridge-ui/public/i18n/en.json +108 -78
  45. package/homebridge-ui/public/i18n/fr.json +108 -78
  46. package/homebridge-ui/public/index.html +95 -106
  47. package/homebridge-ui/public/js/app.js +328 -345
  48. package/homebridge-ui/public/js/dashboard.js +139 -10
  49. package/homebridge-ui/public/js/profile-wizard.js +17 -54
  50. package/i18n/runtime/en.json +5 -3
  51. package/package.json +6 -6
@@ -22,6 +22,13 @@ const dashboardTitle = document.querySelector('[data-dashboard-title]');
22
22
  const dashboardBadge = document.querySelector('[data-dashboard-badge]');
23
23
  const dashboardSummary = document.querySelector('[data-dashboard-summary]');
24
24
  const dashboardAuthenticate = document.querySelector('[data-dashboard-authenticate]');
25
+ const reauthDialog = document.querySelector('[data-reauth-dialog]');
26
+ const reauthAction = document.querySelector('[data-reauth-action]');
27
+ const attentionDialog = document.querySelector('[data-attention-dialog]');
28
+ const attentionTitle = document.querySelector('[data-attention-title]');
29
+ const attentionSummary = document.querySelector('[data-attention-summary]');
30
+ const attentionDiagnose = document.querySelector('[data-attention-diagnose]');
31
+ const attentionChoose = document.querySelector('[data-attention-choose]');
25
32
  const deviceGroups = document.querySelector('[data-device-groups]');
26
33
  const updatePending = document.querySelector('[data-update-pending]');
27
34
  const updatePendingSummary = document.querySelector('[data-update-pending-summary]');
@@ -31,50 +38,34 @@ const pageTitle = document.querySelector('[data-page-title]');
31
38
  const legacyNotice = document.querySelector('[data-legacy-notice]');
32
39
  const legacySettings = document.querySelector('[data-legacy-settings]');
33
40
  const legacyAcknowledge = document.querySelector('[data-legacy-acknowledge]');
41
+ const legacyStatus = document.querySelector('[data-legacy-status]');
34
42
  const menuDiagnostics = document.querySelector('[data-menu-diagnostics]');
35
43
  const menuAdvanced = document.querySelector('[data-menu-advanced]');
36
44
  const diagnosticsPanel = document.querySelector('[data-diagnostics]');
37
45
  const mastheadDiagnostics = document.querySelector('[data-masthead-diagnostics]');
38
46
  const diagnosticsClose = document.querySelector('[data-diagnostics-close]');
39
47
  const diagnosticsWizardPanel = document.querySelector('[data-diagnostics-wizard]');
40
- const diagnosticsQuestion = document.querySelector('[data-diagnostics-question]');
41
48
  const diagnosticsQuestionText = document.querySelector('[data-diagnostics-question-text]');
42
- const diagnosticsYes = document.querySelector('[data-diagnostics-answer="yes"]');
43
- const diagnosticsNo = document.querySelector('[data-diagnostics-answer="no"]');
44
- const diagnosticsDirect = document.querySelector('[data-diagnostics-direct]');
45
- const diagnosticsDirectPanel = document.querySelector('[data-diagnostics-direct-panel]');
46
- const diagnosticsDirectChoose = document.querySelector('[data-diagnostics-direct-choose]');
47
- const diagnosticsDirectBack = document.querySelector('[data-diagnostics-direct-back]');
48
- const diagnosticsFrequency = document.querySelector('[data-diagnostics-frequency]');
49
- const diagnosticsFrequencyHeading = document.querySelector('#diagnostics-frequency-heading');
50
- const diagnosticsFrequencyNow = document.querySelector('[data-diagnostics-frequency-answer="now"]');
51
- const diagnosticsFrequencyIntermittent = document.querySelector('[data-diagnostics-frequency-answer="intermittent"]');
52
- const diagnosticsFrequencyBack = document.querySelector('[data-diagnostics-frequency-back]');
53
- const diagnosticsMatch = document.querySelector('[data-diagnostics-match]');
54
- const diagnosticsReject = document.querySelector('[data-diagnostics-reject]');
55
- const diagnosticsProfile = document.querySelector('[data-diagnostics-profile]');
56
- const diagnosticsAuthorize = document.querySelector('[data-diagnostics-authorize]');
49
+ const diagnosticsTiles = [...document.querySelectorAll('[data-diagnostics-tile]')];
50
+ const diagnosticsCancel = document.querySelector('[data-diagnostics-cancel]');
57
51
  const diagnosticsReproduction = document.querySelector('[data-diagnostics-reproduction]');
58
52
  const diagnosticsStatus = document.querySelector('[data-diagnostics-status]');
59
53
  const diagnosticsIssue = document.querySelector('[data-diagnostics-issue]');
54
+ const diagnosticsExistingIssue = document.querySelector('[data-diagnostics-existing-issue]');
60
55
  const diagnosticsResult = document.querySelector('[data-diagnostics-result]');
61
56
  const diagnosticsActions = document.querySelector('[data-diagnostics-actions]');
62
- const diagnosticsGuidanceTitle = document.querySelector('[data-diagnostics-guidance-title]');
63
- const diagnosticsModeSummary = document.querySelector('[data-diagnostics-mode-summary]');
64
57
  const diagnosticsGuidance = document.querySelector('[data-diagnostics-guidance]');
65
58
  const diagnosticsPhaseTitle = document.querySelector('[data-diagnostics-phase-title]');
66
- const diagnosticsGuidanceBeforeSection = document.querySelector('[data-diagnostics-guidance-before-section]');
67
- const diagnosticsGuidanceBefore = document.querySelector('[data-diagnostics-guidance-before]');
68
59
  const diagnosticsGuidanceAction = document.querySelector('[data-diagnostics-guidance-action]');
69
- const diagnosticsReview = document.querySelector('[data-diagnostics-review]');
70
- const diagnosticsManifest = document.querySelector('[data-diagnostics-manifest]');
71
- const diagnosticsReviewConfirm = document.querySelector('[data-diagnostics-review-confirm]');
72
- const diagnosticsReviewConfirmLabel = document.querySelector('[data-diagnostics-review-confirm-label]');
60
+ const diagnosticsCaptureNote = document.querySelector('[data-diagnostics-capture-note]');
61
+ const diagnosticsHandoff = document.querySelector('[data-diagnostics-handoff]');
62
+ const diagnosticsHandoffNote = document.querySelector('[data-diagnostics-handoff-note]');
63
+ const diagnosticsResultStatus = document.querySelector('[data-diagnostics-result-status]');
73
64
  const diagnosticsExport = document.querySelector('[data-diagnostics-export]');
74
65
  const diagnosticsResultHeading = document.querySelector('[data-diagnostics-result-heading]');
75
66
  const diagnosticsStartAnother = document.querySelector('[data-diagnostics-start-another]');
67
+ const diagnosticsDownloadAgain = document.querySelector('[data-diagnostics-download-again]');
76
68
  const advancedPanel = document.querySelector('[data-advanced-settings]');
77
- const dashboardDiagnose = document.querySelector('[data-dashboard-diagnose]');
78
69
  const devicePanel = document.querySelector('[data-device-settings]');
79
70
  const deviceClose = document.querySelector('[data-device-close]');
80
71
  const deviceSettingsTitle = document.querySelector('[data-device-settings-title]');
@@ -100,18 +91,23 @@ let challenge = '';
100
91
  let legacyNames = [];
101
92
  let legacyAcknowledged = false;
102
93
  let diagnosticsState = { status: 'inactive', missingEvidence: [] };
94
+ /** Whether the reporter already downloaded this session's archive, which is what the report offer follows. */
95
+ let diagnosticsArchiveDownloaded = false;
96
+ /** The session whose archive is reviewed and ready to export, so the review is fetched once rather than per render. */
97
+ let diagnosticsReviewedCaseId = '';
98
+ /** The one fetch in flight, so a burst of renders does not assemble the archive several times over. */
99
+ let diagnosticsReviewRequest;
103
100
  let diagnosticsReviewId = '';
104
- let diagnosticsStartingAnother = false;
105
101
  let panelReturn;
102
+ /** The problem the dashboard last drew, which the dashboard's diagnostics action points at while there is one. */
103
+ let dashboardAttention;
106
104
  let dashboardPanelTrigger;
107
105
  const dashboardView = window.HomebridgeEufyDashboard;
108
106
  const legacySettingsView = window.HomebridgeEufyLegacySettings;
109
107
  const diagnosticsWizard = window.HomebridgeEufyDiagnosticsWizard;
110
- let diagnosticsWizardState = diagnosticsWizard.start();
111
108
  const dashboardElements = {
112
109
  dashboard,
113
110
  title: dashboardTitle,
114
- diagnose: dashboardDiagnose,
115
111
  summary: dashboardSummary,
116
112
  authenticate: dashboardAuthenticate,
117
113
  groups: deviceGroups,
@@ -127,12 +123,19 @@ const dashboardElements = {
127
123
  masthead,
128
124
  };
129
125
 
126
+ const REQUEST_TIMED_OUT = 'Request timed out';
127
+
128
+ /**
129
+ * A request the page stops waiting for after `timeoutMs`. The timeout error carries the request as `late`, since
130
+ * the plugin may still carry it out after the page gave up.
131
+ */
130
132
  function requestWithinDeadline(path, body, timeoutMs = 320000) {
131
133
  let timer;
134
+ const request = homebridge.request(path, body);
132
135
  const timeout = new Promise((_, reject) => {
133
- timer = setTimeout(() => reject(new Error('Request timed out')), timeoutMs);
136
+ timer = setTimeout(() => reject(Object.assign(new Error(REQUEST_TIMED_OUT), { late: request })), timeoutMs);
134
137
  });
135
- return Promise.race([homebridge.request(path, body), timeout]).finally(() => clearTimeout(timer));
138
+ return Promise.race([request, timeout]).finally(() => clearTimeout(timer));
136
139
  }
137
140
 
138
141
  function isDashboardUiReproducing(state = diagnosticsState) {
@@ -148,6 +151,11 @@ function recordActiveUiEventBestEffort(event) {
148
151
  void recordActiveUiEvent(event).catch(() => undefined);
149
152
  }
150
153
 
154
+ /** A country code that is not two letters is refused where it is typed, in the page's own words. */
155
+ countryInput.addEventListener('input', () => {
156
+ countryInput.setCustomValidity(countryInput.validity.patternMismatch ? (messages.countryInvalid ?? '') : '');
157
+ });
158
+
151
159
  acknowledgement.addEventListener('change', () => {
152
160
  continueButton.disabled = !acknowledgement.checked;
153
161
  });
@@ -206,211 +214,150 @@ function setBusy(busy) {
206
214
  }
207
215
 
208
216
  const diagnosticsProfiles = {
209
- 'startup-authentication': {
210
- title: 'diagnosticsProfileStartup',
211
- summary: 'diagnosticsStartupSummary',
212
- before: 'diagnosticsStartupBefore',
213
- action: 'diagnosticsStartupAction',
214
- },
215
- 'device-representation': {
216
- title: 'diagnosticsProfileDevices',
217
- summary: 'diagnosticsDevicesSummary',
218
- before: 'diagnosticsDevicesBefore',
219
- action: 'diagnosticsDevicesAction',
220
- },
221
- 'control-state': {
222
- title: 'diagnosticsProfileControl',
223
- summary: 'diagnosticsControlSummary',
224
- before: 'diagnosticsControlBefore',
225
- action: 'diagnosticsControlAction',
226
- },
227
- 'live-media': {
228
- title: 'diagnosticsProfileLiveMedia',
229
- summary: 'diagnosticsLiveSummary',
230
- before: 'diagnosticsLiveBefore',
231
- action: 'diagnosticsLiveAction',
232
- },
233
- 'hksv-recording': {
234
- title: 'diagnosticsProfileRecording',
235
- summary: 'diagnosticsRecordingSummary',
236
- before: 'diagnosticsRecordingBefore',
237
- action: 'diagnosticsRecordingAction',
238
- },
239
- 'dashboard-ui': {
240
- title: 'diagnosticsProfileDashboard',
241
- summary: 'diagnosticsDashboardSummary',
242
- before: 'diagnosticsDashboardBefore',
243
- action: 'diagnosticsDashboardAction',
244
- },
245
- other: {
246
- title: 'diagnosticsProfileOther',
247
- summary: 'diagnosticsOtherSummary',
248
- before: 'diagnosticsOtherBefore',
249
- action: 'diagnosticsOtherAction',
250
- },
217
+ 'startup-authentication': { title: 'diagnosticsProfileStartup', action: 'diagnosticsStartupAction' },
218
+ 'device-representation': { title: 'diagnosticsProfileDevices', action: 'diagnosticsDevicesAction' },
219
+ 'control-state': { title: 'diagnosticsProfileControl', action: 'diagnosticsControlAction' },
220
+ 'live-media': { title: 'diagnosticsProfileLiveMedia', action: 'diagnosticsLiveAction' },
221
+ 'hksv-recording': { title: 'diagnosticsProfileRecording', action: 'diagnosticsRecordingAction' },
222
+ 'dashboard-ui': { title: 'diagnosticsProfileDashboard', action: 'diagnosticsDashboardAction' },
223
+ other: { title: 'diagnosticsProfileOther', action: 'diagnosticsOtherAction' },
251
224
  };
252
225
 
253
226
  function renderDiagnosticsGuidance(profile) {
254
227
  const guidance = diagnosticsProfiles[profile] ?? diagnosticsProfiles.other;
255
- diagnosticsGuidanceTitle.textContent = messages[guidance.title] ?? '';
256
228
  diagnosticsPhaseTitle.textContent = messages[guidance.title] ?? '';
257
- diagnosticsGuidanceBefore.textContent = messages[guidance.before] ?? '';
258
229
  diagnosticsGuidanceAction.textContent = messages[guidance.action] ?? '';
259
230
  }
260
231
 
261
- function renderDiagnosticsWizard() {
262
- diagnosticsQuestion.hidden = diagnosticsWizardState.mode !== 'questions';
263
- diagnosticsDirectPanel.hidden = diagnosticsWizardState.mode !== 'direct';
264
- diagnosticsFrequency.hidden = diagnosticsWizardState.mode !== 'frequency';
265
- diagnosticsMatch.hidden = diagnosticsWizardState.mode !== 'match';
266
- if (diagnosticsWizardState.mode === 'questions') {
267
- const question = diagnosticsWizard.questions[diagnosticsWizardState.questionIndex];
268
- diagnosticsQuestionText.textContent = messages[question.message] ?? '';
269
- }
270
- if (diagnosticsWizardState.mode === 'match') {
271
- diagnosticsProfile.value = diagnosticsWizardState.profile;
272
- renderDiagnosticsGuidance(diagnosticsWizardState.profile);
273
- diagnosticsModeSummary.textContent =
274
- messages[
275
- diagnosticsWizardState.reproductionMode === 'intermittent'
276
- ? 'diagnosticsModeIntermittentSummary'
277
- : 'diagnosticsModeNowSummary'
278
- ] ?? '';
232
+ /**
233
+ * The report step, reachable or not.
234
+ *
235
+ * Without an archive there is nothing to attach, so the anchor carries no `href` — it is not a link — and
236
+ * `aria-disabled` states the same thing to a reader who cannot see it greyed. The hint says why, because a
237
+ * control that cannot be used and does not explain itself reads as broken rather than as not yet.
238
+ */
239
+ function setIssueStepReachable(url) {
240
+ diagnosticsIssue.href = url;
241
+ diagnosticsExistingIssue.href = url.replace(/\/issues\/new.*$/, '/issues?q=is%3Aissue+involves%3A%40me');
242
+ }
243
+
244
+ /**
245
+ * Leaves the archive dialog once its file is handed over, which is what ends the session: the plugin keeps it until
246
+ * then so a download that never arrived can be tried again.
247
+ */
248
+ async function endDiagnosticsCase() {
249
+ try {
250
+ renderDiagnostics(await requestWithinDeadline('/diagnostics/cancel', undefined, 12000));
251
+ } catch (error) {
252
+ await redrawDiagnosticsAfterFailure('inactive', error);
279
253
  }
254
+ diagnosticsQuestionText.focus?.();
280
255
  }
281
256
 
282
257
  function renderDiagnostics(state) {
258
+ if (state.supportCaseId !== diagnosticsState.supportCaseId) diagnosticsArchiveDownloaded = false;
283
259
  diagnosticsState = state;
284
- const screen = diagnosticsWizard.screen(state, diagnosticsStartingAnother);
260
+ const screen = diagnosticsWizard.screen(state);
285
261
  const choosing = screen === 'choose';
286
262
  const reviewing = screen === 'review';
287
- const reproductionMode = state.reproductionMode ?? 'now';
288
- const collecting = isDashboardUiReproducing(state);
289
- for (const entry of [mastheadDiagnostics, menuDiagnostics]) {
290
- if (collecting) entry.dataset.collecting = 'true';
291
- else delete entry.dataset.collecting;
292
- entry.setAttribute(
293
- 'aria-label',
294
- messages[collecting ? 'diagnosticsCollectingFinishHere' : 'menuDiagnostics'] ?? '',
295
- );
296
- }
263
+ renderDiagnosticsEntries();
297
264
  diagnosticsWizardPanel.hidden = !choosing;
298
265
  diagnosticsGuidance.hidden = screen !== 'reproduce';
299
- diagnosticsGuidanceBeforeSection.hidden = state.status === 'reproducing';
300
266
  diagnosticsActions.hidden = screen !== 'reproduce';
301
- diagnosticsReproduction.disabled = !['authorized', 'reproducing'].includes(state.status);
302
- diagnosticsReproduction.textContent =
303
- reproductionMode === 'intermittent'
304
- ? state.status === 'reproducing'
305
- ? messages.diagnosticsIntermittentIssueHappened
306
- : messages.diagnosticsIntermittentStartWaiting
307
- : state.status === 'reproducing'
308
- ? messages.diagnosticsNowFinish
309
- : messages.diagnosticsStartRecording;
310
- diagnosticsIssue.hidden = !reviewing;
311
- diagnosticsIssue.href = state.issueUrl ?? '';
312
- diagnosticsResult.hidden = !reviewing;
313
- diagnosticsReview.hidden = !reviewing;
314
- diagnosticsManifest.hidden = true;
315
- diagnosticsReviewConfirmLabel.hidden = true;
316
- diagnosticsReviewConfirm.checked = false;
317
- diagnosticsExport.hidden = true;
318
- diagnosticsExport.disabled = true;
319
- diagnosticsReviewId = '';
320
- if (state.profile) diagnosticsProfile.value = state.profile;
267
+ diagnosticsReproduction.disabled = state.status !== 'reproducing';
268
+ setIssueStepReachable(diagnosticsArchiveDownloaded ? (state.issueUrl ?? '') : '');
269
+ const offering = (reviewing || diagnosticsArchiveDownloaded) && !diagnosticsPanel.hidden;
270
+ if (offering && !diagnosticsResult.open) diagnosticsResult.showModal?.();
271
+ if (!offering && diagnosticsResult.open) diagnosticsResult.close?.();
272
+ diagnosticsResultHeading.textContent =
273
+ messages[
274
+ diagnosticsArchiveDownloaded
275
+ ? 'diagnosticsArchiveDownloaded'
276
+ : state.reproductionEndedAt && state.reproductionEndedAt === state.expiresAt
277
+ ? 'diagnosticsCaptureTimedOut'
278
+ : 'diagnosticsEvidenceReady'
279
+ ] ?? '';
280
+ const reviewed = reviewing && diagnosticsReviewedCaseId === (state.supportCaseId ?? '');
281
+ diagnosticsExport.hidden = !reviewed || diagnosticsArchiveDownloaded;
282
+ diagnosticsHandoff.hidden = !diagnosticsArchiveDownloaded;
283
+ diagnosticsHandoffNote.hidden = !diagnosticsArchiveDownloaded;
284
+ if (!offering) diagnosticsResultStatus.textContent = '';
285
+ else if (!diagnosticsArchiveDownloaded && state.missingEvidence?.length) {
286
+ diagnosticsResultStatus.textContent = messages.diagnosticsMissingEvidence ?? '';
287
+ }
288
+ diagnosticsStartAnother.hidden = !diagnosticsArchiveDownloaded;
289
+ diagnosticsDownloadAgain.hidden = !diagnosticsArchiveDownloaded;
290
+ if (!reviewed) {
291
+ diagnosticsExport.disabled = true;
292
+ diagnosticsReviewId = '';
293
+ if (reviewing && !diagnosticsArchiveDownloaded) void ensureArchiveReview(state.supportCaseId ?? '');
294
+ }
321
295
  if (screen === 'reproduce') {
322
296
  renderDiagnosticsGuidance(state.profile);
323
- if (reproductionMode === 'intermittent') {
324
- diagnosticsGuidanceBeforeSection.hidden = true;
325
- diagnosticsGuidanceAction.textContent =
326
- messages[
327
- state.status === 'reproducing'
328
- ? 'diagnosticsIntermittentWaitingGuidance'
329
- : 'diagnosticsIntermittentReadyGuidance'
330
- ] ?? '';
331
- }
297
+ const stopsAt = new Date(state.expiresAt).toLocaleString(shell.lang || undefined, {
298
+ weekday: 'long',
299
+ hour: 'numeric',
300
+ minute: '2-digit',
301
+ });
302
+ diagnosticsCaptureNote.textContent = (messages.diagnosticsCaptureKeepsRunning ?? '').replace('{time}', stopsAt);
332
303
  }
333
- if (choosing) renderDiagnosticsWizard();
334
- const statusKey = reviewing
335
- ? state.missingEvidence?.length
336
- ? 'diagnosticsMissingEvidence'
337
- : 'diagnosticsComplete'
338
- : {
339
- inactive: 'diagnosticsInactive',
340
- authorized:
341
- reproductionMode === 'intermittent' ? 'diagnosticsIntermittentAuthorized' : 'diagnosticsAuthorized',
342
- reproducing:
343
- reproductionMode === 'intermittent' ? 'diagnosticsIntermittentReproducing' : 'diagnosticsReproducing',
344
- complete: state.missingEvidence?.length ? 'diagnosticsMissingEvidence' : 'diagnosticsComplete',
345
- expired: 'diagnosticsExpired',
346
- }[state.status];
347
- diagnosticsStatus.textContent = (
348
- choosing && (state.status === 'inactive' || diagnosticsStartingAnother) ? '' : (messages[statusKey] ?? '')
349
- ).replace('{evidence}', state.missingEvidence?.join(', ') ?? '');
304
+ diagnosticsStatus.textContent = '';
350
305
  }
351
306
 
352
- function renderArchiveManifest(manifest) {
353
- diagnosticsManifest.replaceChildren();
354
- const summary = document.createElement('p');
355
- const mode =
356
- messages[manifest.reproductionMode === 'intermittent' ? 'diagnosticsModeIntermittent' : 'diagnosticsModeNow'] ??
357
- manifest.reproductionMode;
358
- summary.textContent = `${manifest.archiveFormat} v${manifest.version} · ${manifest.keyId} · ${messages.diagnosticsArchiveMode ?? ''} ${mode} · ${(messages.diagnosticsArchiveExpires ?? '').replace('{expiresAt}', new Date(manifest.archiveExpiresAt).toLocaleString(shell.lang || 'en'))}`;
359
- const evidence = document.createElement('ul');
360
- for (const item of manifest.evidence ?? []) {
361
- const row = document.createElement('li');
362
- const size = item.bytes === undefined ? '' : ` · ${item.bytes} B`;
363
- const truncated = item.truncated ? ` · ${messages.diagnosticsArchiveTruncated ?? ''}` : '';
364
- const missing = item.missingReason ? ` · ${item.missingReason}` : '';
365
- const fields = (item.fields ?? []).map((field) => `${field.field}: ${field.privacyClass}`).join(', ');
366
- row.textContent = `${item.evidence} · ${item.privacyClass} · ${item.status}${missing}${size}${truncated}${fields ? ` · ${messages.diagnosticsArchiveFields ?? ''} ${fields}` : ''}`;
367
- evidence.append(row);
368
- }
369
- const exclusions = document.createElement('p');
370
- exclusions.textContent = `${messages.diagnosticsArchiveExcluded ?? ''} ${(manifest.excludedClasses ?? []).join(', ')}`;
371
- diagnosticsManifest.append(summary, evidence, exclusions);
372
- const uncovered = (manifest.evidence ?? []).filter((item) => item.coversReproduction === false);
373
- if (uncovered.length > 0) {
374
- const gap = document.createElement('p');
375
- gap.textContent = (messages.diagnosticsArchiveCoverageGap ?? '')
376
- .replace('{classes}', uncovered.map((item) => item.evidence).join(', '))
377
- .replace(
378
- '{retainedFrom}',
379
- new Date(
380
- Math.min(...uncovered.map((item) => Date.parse(item.retainedFrom))),
381
- ).toLocaleString(shell.lang || 'en'),
382
- );
383
- diagnosticsManifest.append(gap);
307
+ /**
308
+ * Marks both diagnostics actions while a capture runs, whichever area it is on, and otherwise the dashboard's own
309
+ * while it shows a problem.
310
+ */
311
+ function renderDiagnosticsEntries() {
312
+ const collecting = diagnosticsState.status === 'reproducing';
313
+ for (const entry of [mastheadDiagnostics, menuDiagnostics]) {
314
+ const attending = !collecting && entry === menuDiagnostics && dashboardAttention !== undefined;
315
+ if (collecting) entry.dataset.collecting = 'true';
316
+ else delete entry.dataset.collecting;
317
+ if (attending) entry.dataset.attention = 'true';
318
+ else delete entry.dataset.attention;
319
+ entry.setAttribute(
320
+ 'aria-label',
321
+ messages[collecting ? 'diagnosticsCollectingFinishHere' : attending ? 'diagnosticsAttention' : 'menuDiagnostics'] ??
322
+ '',
323
+ );
384
324
  }
385
- diagnosticsManifest.hidden = false;
386
325
  }
387
326
 
388
- diagnosticsReview.addEventListener('click', async () => {
389
- diagnosticsReview.disabled = true;
390
- try {
391
- const review = await requestWithinDeadline('/diagnostics/archive/review', undefined, 12000);
392
- diagnosticsReviewId = review.reviewId;
393
- renderArchiveManifest(review.manifest);
394
- diagnosticsReviewConfirmLabel.hidden = false;
395
- diagnosticsExport.hidden = false;
396
- } catch {
327
+ /**
328
+ * The reviewed archive for the session on screen, fetched once.
329
+ *
330
+ * A completed reproduction that the reporter is looking at is a file they came for, so the archive is
331
+ * reviewed without being asked for. It is reviewed once per session rather than per render, because
332
+ * assembling it reads every collected log. Exporting spends it, and ends the session with it.
333
+ */
334
+ function ensureArchiveReview(caseId) {
335
+ if (!caseId || diagnosticsReviewedCaseId === caseId) return Promise.resolve();
336
+ diagnosticsReviewRequest ??= (async () => {
397
337
  try {
398
- renderDiagnostics(await requestWithinDeadline('/diagnostics/status', undefined, 12000));
399
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
338
+ const review = await requestWithinDeadline('/diagnostics/archive/review', undefined, 12000);
339
+ diagnosticsReviewId = review.reviewId;
340
+ diagnosticsReviewedCaseId = caseId;
341
+ diagnosticsExport.hidden = false;
342
+ diagnosticsExport.disabled = false;
400
343
  } catch {
401
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
344
+ diagnosticsResultStatus.textContent = messages.diagnosticsFailed ?? '';
345
+ } finally {
346
+ diagnosticsReviewRequest = undefined;
402
347
  }
403
- } finally {
404
- diagnosticsReview.disabled = false;
405
- }
406
- });
407
-
408
- diagnosticsReviewConfirm.addEventListener('change', () => {
409
- diagnosticsExport.disabled = !diagnosticsReviewConfirm.checked || !diagnosticsReviewId;
410
- });
348
+ })();
349
+ return diagnosticsReviewRequest;
350
+ }
411
351
 
412
- diagnosticsExport.addEventListener('click', async () => {
413
- if (!diagnosticsReviewConfirm.checked || !diagnosticsReviewId) return;
352
+ /**
353
+ * Hands the archive of the session on screen over as a download.
354
+ *
355
+ * Finishing a reproduction calls this without being asked, since the file is what the reporter finished for; the
356
+ * dialog's own button is the way to try again after a failed attempt.
357
+ */
358
+ async function downloadDiagnosticsArchive() {
359
+ await ensureArchiveReview(diagnosticsState.supportCaseId ?? '');
360
+ if (!diagnosticsReviewId) return;
414
361
  diagnosticsExport.disabled = true;
415
362
  try {
416
363
  const exported = await requestWithinDeadline(
@@ -421,141 +368,119 @@ diagnosticsExport.addEventListener('click', async () => {
421
368
  const download = document.createElement('a');
422
369
  download.href = `data:${exported.mediaType};base64,${exported.archive}`;
423
370
  download.download = exported.filename;
371
+ diagnosticsHandoffNote.textContent = (messages.diagnosticsArchiveHandoff ?? '').replace(
372
+ '{filename}',
373
+ exported.filename,
374
+ );
375
+ diagnosticsResultStatus.textContent = '';
424
376
  document.body.appendChild(download);
425
377
  download.click();
426
378
  document.body.removeChild(download);
427
379
  diagnosticsReviewId = '';
428
- diagnosticsReviewConfirm.checked = false;
380
+ diagnosticsReviewedCaseId = '';
381
+ diagnosticsArchiveDownloaded = true;
382
+ renderDiagnostics(diagnosticsState);
383
+ diagnosticsResultHeading.focus?.();
429
384
  } catch {
430
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
385
+ // The plugin spends a review on any attempt, so trying again asks for a fresh one.
386
+ diagnosticsReviewId = '';
387
+ diagnosticsReviewedCaseId = '';
388
+ diagnosticsExport.disabled = false;
389
+ diagnosticsResultStatus.textContent = messages.diagnosticsFailed ?? '';
431
390
  }
432
- });
391
+ }
392
+
393
+ diagnosticsExport.addEventListener('click', downloadDiagnosticsArchive);
394
+ diagnosticsDownloadAgain.addEventListener('click', downloadDiagnosticsArchive);
395
+
396
+ /**
397
+ * Draws the session as the plugin holds it after `error`, and says so unless it reached `expected`, inside the
398
+ * download dialog while that covers the panel. A request that timed out may still land, so its late answer draws
399
+ * the session again.
400
+ */
401
+ async function redrawDiagnosticsAfterFailure(expected, error) {
402
+ const refreshed = await settleDiagnostics().catch(() => undefined);
403
+ if (refreshed?.status !== expected) {
404
+ (diagnosticsResult.open ? diagnosticsResultStatus : diagnosticsStatus).textContent = messages.diagnosticsFailed ?? '';
405
+ }
406
+ error?.late?.then(settleDiagnostics).catch(() => undefined);
407
+ }
408
+
409
+ /**
410
+ * Draws the session the plugin holds. One opened but never started is what a failed or late pick leaves behind;
411
+ * nothing on screen stands for it and it keeps detailed logging on, so it is cancelled rather than drawn.
412
+ */
413
+ async function settleDiagnostics() {
414
+ let state = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
415
+ if (state.status === 'authorized') state = await requestWithinDeadline('/diagnostics/cancel', undefined, 12000);
416
+ renderDiagnostics(state);
417
+ return state;
418
+ }
433
419
 
434
- diagnosticsAuthorize.addEventListener('click', async () => {
435
- diagnosticsAuthorize.disabled = true;
436
- let focusAuthorizeAfter = false;
437
- const previousStatus = diagnosticsState.status;
420
+ /**
421
+ * Captures the area picked. The area is the only answer a capture needs, so the pick opens the session and starts
422
+ * it in one go.
423
+ */
424
+ async function startDiagnosticsCapture(profile) {
425
+ for (const tile of diagnosticsTiles) tile.disabled = true;
438
426
  try {
439
- const authorized = await requestWithinDeadline(
440
- '/diagnostics/authorize',
441
- {
442
- profile: diagnosticsWizardState.profile,
443
- reproductionMode: diagnosticsWizardState.reproductionMode,
444
- },
445
- 12000,
446
- );
447
- diagnosticsStartingAnother = false;
448
- renderDiagnostics(authorized);
449
- if (diagnosticsState.status === 'authorized') diagnosticsGuidance.focus?.();
450
- } catch {
451
- try {
452
- const refreshed = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
453
- if (refreshed.status !== previousStatus) diagnosticsStartingAnother = false;
454
- renderDiagnostics(refreshed);
455
- if (refreshed.status === previousStatus) {
456
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
457
- focusAuthorizeAfter = true;
458
- } else if (refreshed.status === 'authorized') diagnosticsGuidance.focus?.();
459
- } catch {
460
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
427
+ await requestWithinDeadline('/diagnostics/authorize', { profile }, 12000);
428
+ const state = await requestWithinDeadline('/diagnostics/reproduction/start', undefined, 12000);
429
+ renderDiagnostics(state);
430
+ if (isDashboardUiReproducing(state)) {
431
+ await recordActiveUiEvent('background-started').catch(() => undefined);
432
+ closeDashboardPanel();
433
+ return;
461
434
  }
435
+ diagnosticsPhaseTitle.focus?.();
436
+ } catch (error) {
437
+ await redrawDiagnosticsAfterFailure('reproducing', error);
462
438
  } finally {
463
- diagnosticsAuthorize.disabled = false;
464
- if (focusAuthorizeAfter) diagnosticsAuthorize.focus?.();
439
+ for (const tile of diagnosticsTiles) tile.disabled = false;
465
440
  }
466
- });
467
-
468
- diagnosticsYes.addEventListener('click', () => {
469
- diagnosticsWizardState = diagnosticsWizard.answer(diagnosticsWizardState, true);
470
- renderDiagnosticsWizard();
471
- diagnosticsFrequencyHeading.focus?.();
472
- });
473
-
474
- diagnosticsNo.addEventListener('click', () => {
475
- diagnosticsWizardState = diagnosticsWizard.answer(diagnosticsWizardState, false);
476
- renderDiagnosticsWizard();
477
- if (diagnosticsWizardState.mode === 'frequency') diagnosticsFrequencyHeading.focus?.();
478
- else diagnosticsQuestionText.focus?.();
479
- });
480
-
481
- diagnosticsDirect.addEventListener('click', () => {
482
- diagnosticsWizardState = diagnosticsWizard.direct();
483
- renderDiagnosticsWizard();
484
- diagnosticsProfile.focus?.();
485
- });
486
-
487
- diagnosticsDirectChoose.addEventListener('click', () => {
488
- diagnosticsWizardState = diagnosticsWizard.selectDirect(diagnosticsWizardState, diagnosticsProfile.value);
489
- renderDiagnosticsWizard();
490
- diagnosticsFrequencyHeading.focus?.();
491
- });
492
-
493
- diagnosticsDirectBack.addEventListener('click', () => {
494
- diagnosticsWizardState = diagnosticsWizard.start();
495
- renderDiagnosticsWizard();
496
- diagnosticsQuestionText.focus?.();
497
- });
498
-
499
- function chooseDiagnosticsReproductionMode(reproductionMode) {
500
- diagnosticsWizardState = diagnosticsWizard.chooseReproductionMode(diagnosticsWizardState, reproductionMode);
501
- renderDiagnosticsWizard();
502
- diagnosticsGuidanceTitle.focus?.();
503
441
  }
504
442
 
505
- diagnosticsFrequencyNow.addEventListener('click', () => chooseDiagnosticsReproductionMode('now'));
506
- diagnosticsFrequencyIntermittent.addEventListener('click', () => chooseDiagnosticsReproductionMode('intermittent'));
507
-
508
- diagnosticsFrequencyBack.addEventListener('click', () => {
509
- diagnosticsWizardState = diagnosticsWizard.backFromFrequency(diagnosticsWizardState);
510
- renderDiagnosticsWizard();
511
- if (diagnosticsWizardState.mode === 'direct') diagnosticsProfile.focus?.();
512
- else diagnosticsQuestionText.focus?.();
513
- });
514
-
515
- diagnosticsReject.addEventListener('click', () => {
516
- diagnosticsWizardState = diagnosticsWizard.reject(diagnosticsWizardState);
517
- renderDiagnosticsWizard();
518
- if (diagnosticsWizardState.mode === 'direct') diagnosticsProfile.focus?.();
519
- else diagnosticsQuestionText.focus?.();
520
- });
443
+ for (const tile of diagnosticsTiles) {
444
+ tile.addEventListener('click', () => startDiagnosticsCapture(tile.dataset.diagnosticsTile));
445
+ }
521
446
 
522
- diagnosticsStartAnother.addEventListener('click', () => {
523
- diagnosticsStartingAnother = true;
524
- diagnosticsWizardState = diagnosticsWizard.start();
525
- renderDiagnostics(diagnosticsState);
526
- diagnosticsQuestionText.focus?.();
447
+ diagnosticsStartAnother.addEventListener('click', endDiagnosticsCase);
448
+ /** Escape leaves the archive dialog only once its file is downloaded, and leaving it finishes the session. */
449
+ diagnosticsResult.addEventListener('cancel', (event) => {
450
+ event.preventDefault();
451
+ if (diagnosticsArchiveDownloaded) endDiagnosticsCase();
527
452
  });
528
453
 
529
454
  diagnosticsReproduction.addEventListener('click', async () => {
530
455
  diagnosticsReproduction.disabled = true;
531
- const previousStatus = diagnosticsState.status;
532
- const path =
533
- diagnosticsState.status === 'reproducing' ? '/diagnostics/reproduction/end' : '/diagnostics/reproduction/start';
534
456
  try {
535
- let state = await requestWithinDeadline(path, undefined, 12000);
457
+ let state = await requestWithinDeadline('/diagnostics/reproduction/end', undefined, 12000);
536
458
  if (state.status === 'complete' && state.missingEvidence?.length) {
537
459
  await new Promise((resolve) => setTimeout(resolve, 250));
538
460
  state = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
539
461
  }
540
462
  renderDiagnostics(state);
541
- if (previousStatus === 'authorized' && isDashboardUiReproducing(state)) {
542
- await recordActiveUiEvent('background-started').catch(() => undefined);
543
- closeDashboardPanel();
544
- return;
545
- }
546
- if (state.status === 'complete' || state.partialExportAvailable) diagnosticsResultHeading.focus?.();
547
- } catch {
548
- try {
549
- const refreshed = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
550
- renderDiagnostics(refreshed);
551
- if (refreshed.status === previousStatus) diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
552
- if (refreshed.status === 'complete' || refreshed.partialExportAvailable) diagnosticsResultHeading.focus?.();
553
- else if (refreshed.status === 'authorized' || refreshed.status === 'reproducing') diagnosticsGuidance.focus?.();
554
- } catch {
555
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
556
- }
463
+ } catch (error) {
464
+ await redrawDiagnosticsAfterFailure('complete', error);
465
+ } finally {
466
+ diagnosticsReproduction.disabled = diagnosticsState.status !== 'reproducing';
467
+ }
468
+ if (diagnosticsState.partialExportAvailable) {
469
+ diagnosticsResultHeading.focus?.();
470
+ await downloadDiagnosticsArchive();
471
+ }
472
+ });
473
+
474
+ /** Cancelling a capture deletes its session without a file, and puts the areas back on the panel. */
475
+ diagnosticsCancel.addEventListener('click', async () => {
476
+ diagnosticsCancel.disabled = true;
477
+ try {
478
+ renderDiagnostics(await requestWithinDeadline('/diagnostics/cancel', undefined, 12000));
479
+ diagnosticsQuestionText.focus?.();
480
+ } catch (error) {
481
+ await redrawDiagnosticsAfterFailure('inactive', error);
557
482
  } finally {
558
- diagnosticsReproduction.disabled = !['authorized', 'reproducing'].includes(diagnosticsState.status);
483
+ diagnosticsCancel.disabled = false;
559
484
  }
560
485
  });
561
486
 
@@ -612,21 +537,46 @@ function closeDashboardPanel() {
612
537
  }
613
538
  }
614
539
 
615
- mastheadDiagnostics.addEventListener('click', () => menuDiagnostics.click());
616
-
617
- // The dashboard's own offer of a run goes the same way the menu does, so there is one path into the wizard.
618
- dashboardDiagnose.addEventListener('click', () => menuDiagnostics.click());
619
-
620
- menuDiagnostics.addEventListener('click', async () => {
540
+ /**
541
+ * Opens the diagnostics panel. A preset is an area the reporter already chose to diagnose from a problem the
542
+ * dashboard named beside the privacy note, so a panel with nothing under way captures it at once. Opened over the
543
+ * sign-in screen, the panel asks like any other and puts focus on the startup area.
544
+ */
545
+ async function openDiagnostics(preset) {
546
+ const signingIn = !setupContent.hidden;
621
547
  openDashboardPanel(diagnosticsPanel, menuDiagnostics);
622
- // Drawn on opening, because the wizard's first question is only ever put on the page by this call.
623
- renderDiagnosticsWizard();
624
548
  try {
625
549
  renderDiagnostics(await requestWithinDeadline('/diagnostics/status', undefined, 12000));
626
550
  } catch {
627
551
  diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
628
552
  recordActiveUiEventBestEffort('request-failed');
553
+ return;
554
+ }
555
+ if (diagnosticsWizard.screen(diagnosticsState) !== 'choose') return;
556
+ if (preset) await startDiagnosticsCapture(preset.profile);
557
+ else if (signingIn) {
558
+ diagnosticsTiles.find((tile) => tile.dataset.diagnosticsTile === 'startup-authentication')?.focus?.();
629
559
  }
560
+ }
561
+
562
+ mastheadDiagnostics.addEventListener('click', () => openDiagnostics());
563
+
564
+ /** While the dashboard shows a problem and no session is under way, the action names it before opening the wizard. */
565
+ menuDiagnostics.addEventListener('click', () => {
566
+ if (!menuDiagnostics.dataset.attention || diagnosticsWizard.screen(diagnosticsState) !== 'choose') {
567
+ return openDiagnostics();
568
+ }
569
+ attentionTitle.textContent = dashboardAttention.title;
570
+ attentionSummary.textContent = dashboardAttention.summary;
571
+ attentionDialog.showModal?.();
572
+ });
573
+ attentionDiagnose.addEventListener('click', () => {
574
+ attentionDialog.close?.();
575
+ return openDiagnostics(dashboardAttention);
576
+ });
577
+ attentionChoose.addEventListener('click', () => {
578
+ attentionDialog.close?.();
579
+ return openDiagnostics();
630
580
  });
631
581
  menuAdvanced.addEventListener('click', async () => {
632
582
  const config = configuredBlock() ?? {};
@@ -653,9 +603,8 @@ menuAdvanced.addEventListener('click', async () => {
653
603
  }
654
604
  });
655
605
  diagnosticsClose.addEventListener('click', () => {
656
- diagnosticsStartingAnother = false;
657
- renderDiagnostics(diagnosticsState);
658
606
  closeDashboardPanel();
607
+ renderDiagnostics(diagnosticsState);
659
608
  });
660
609
  advancedClose.addEventListener('click', () => {
661
610
  closeDashboardPanel();
@@ -838,7 +787,7 @@ dashboardView.bindPreferences(dashboardElements, configuredBlock, updateConfig,
838
787
  openDashboardPanel(devicePanel, trigger);
839
788
  });
840
789
 
841
- dashboardAuthenticate.addEventListener('click', () => {
790
+ function openAuthentication() {
842
791
  diagnosticsPanel.hidden = true;
843
792
  advancedPanel.hidden = true;
844
793
  dashboard.hidden = true;
@@ -847,6 +796,16 @@ dashboardAuthenticate.addEventListener('click', () => {
847
796
  pageTitle.textContent = messages.pageTitle;
848
797
  accountInput.focus?.();
849
798
  recordActiveUiEventBestEffort('authentication-opened');
799
+ }
800
+
801
+ dashboardAuthenticate.addEventListener('click', openAuthentication);
802
+
803
+ // A saved session the plugin can no longer use stops every device, so nothing on the page works until the user
804
+ // signs in again. The dialog says so in front of everything else, and Escape does not dismiss it.
805
+ reauthDialog.addEventListener('cancel', (event) => event.preventDefault());
806
+ reauthAction.addEventListener('click', () => {
807
+ reauthDialog.close?.();
808
+ openAuthentication();
850
809
  });
851
810
 
852
811
  legacyAcknowledge.addEventListener('click', async () => {
@@ -856,12 +815,19 @@ legacyAcknowledge.addEventListener('click', async () => {
856
815
  try {
857
816
  await updateConfig({ ...existing, discardedV4Settings: legacyNames, discardedV4Acknowledged: true });
858
817
  } catch {
818
+ legacyStatus.textContent = messages.preferenceSaveFailed ?? '';
859
819
  return;
860
820
  }
861
821
  }
862
822
  legacyNotice.hidden = true;
863
823
  });
864
824
 
825
+ /**
826
+ * Writes the signed-in account into the Homebridge configuration, reporting whether it was saved.
827
+ *
828
+ * The Save button is enabled before the attempt and never disabled by it, so a write that fails leaves the user
829
+ * the one control that retries it.
830
+ */
865
831
  async function saveAuthenticatedConfig() {
866
832
  homebridge.enableSaveButton();
867
833
  try {
@@ -870,7 +836,9 @@ async function saveAuthenticatedConfig() {
870
836
  pluginConfig = [pendingConfig];
871
837
  savedConfigSignature = configSignature(pluginConfig);
872
838
  homebridge.enableSaveButton();
839
+ return true;
873
840
  } catch {
841
+ return false;
874
842
  } finally {
875
843
  passwordInput.value = '';
876
844
  pendingConfig = undefined;
@@ -890,18 +858,35 @@ async function showDashboard() {
890
858
  warmUpCandidates = Array.isArray(snapshot.warmUpCandidates) ? snapshot.warmUpCandidates : [];
891
859
  // Kept beside the render that drew them, so opening a tile finds the device that tile stands for.
892
860
  dashboardDevices = snapshot.devices ?? [];
893
- dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
861
+ dashboardAttention = dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
862
+ if (snapshot.state === 'authentication-required' && snapshot.devices?.length > 0 && !reauthDialog.open) {
863
+ reauthDialog.showModal?.();
864
+ }
894
865
  // The image pass runs beside the rendered dashboard, so its failure is dropped here rather than escaping.
895
866
  void dashboardView
896
867
  .applyDeviceImages(dashboardElements, (serial) => requestWithinDeadline('/device/image', { serial }, 12000))
897
868
  .catch(() => undefined);
898
869
  } catch {
899
- dashboardView.render({ state: 'missing', devices: [] }, configuredBlock() ?? {}, messages, dashboardElements);
870
+ dashboardAttention = dashboardView.render(
871
+ { state: 'unreachable', devices: [] },
872
+ configuredBlock() ?? {},
873
+ messages,
874
+ dashboardElements,
875
+ );
900
876
  recordActiveUiEventBestEffort('request-failed');
901
877
  }
878
+ renderDiagnosticsEntries();
902
879
  recordActiveUiEventBestEffort('dashboard-opened');
903
880
  }
904
881
 
882
+ /** The message each unsuccessful sign-in outcome is stated with; any other outcome is a failed sign-in. */
883
+ const AUTH_OUTCOMES = {
884
+ blocked: 'dashboardOwnerConflictSummary',
885
+ 'plugin-running': 'authPluginRunning',
886
+ 'commit-failed': 'authCommitFailed',
887
+ 'timed-out': 'authTimedOut',
888
+ };
889
+
905
890
  async function handleResult(result) {
906
891
  if (result.status === 'captcha') {
907
892
  challenge = 'captcha';
@@ -910,7 +895,7 @@ async function handleResult(result) {
910
895
  challengeImage.hidden = false;
911
896
  challengeLabel.textContent = messages.captchaLabel ?? '';
912
897
  challengeForm.hidden = false;
913
- authStatus.textContent = '';
898
+ authStatus.textContent = result.retry ? (messages.captchaRetry ?? '') : '';
914
899
  return;
915
900
  }
916
901
  if (result.status === 'two-factor') {
@@ -919,7 +904,7 @@ async function handleResult(result) {
919
904
  challengeImage.hidden = true;
920
905
  challengeLabel.textContent = messages.twoFactorLabel ?? '';
921
906
  challengeForm.hidden = false;
922
- authStatus.textContent = result.method;
907
+ authStatus.textContent = messages.twoFactorSent ?? '';
923
908
  return;
924
909
  }
925
910
 
@@ -927,21 +912,21 @@ async function handleResult(result) {
927
912
  if (result.status === 'restart-required') {
928
913
  authForm.hidden = true;
929
914
  authStatus.textContent = messages.authSuccess ?? '';
930
- await saveAuthenticatedConfig();
931
915
  // The devices this sign-in discovered are already recorded, so the flow ends on them rather than on a notice.
932
- await showDashboard();
933
- } else if (result.status === 'blocked' || result.status === 'plugin-running') {
934
- authForm.hidden = false;
935
- authStatus.textContent = messages.authBlocked ?? '';
936
- } else if (result.status === 'timed-out') {
937
- authForm.hidden = false;
938
- authStatus.textContent = messages.authTimedOut ?? '';
916
+ if (await saveAuthenticatedConfig()) await showDashboard();
917
+ else authStatus.textContent = messages.authSaveFailed ?? '';
939
918
  } else {
940
919
  authForm.hidden = false;
941
- authStatus.textContent = messages.authFailed ?? '';
920
+ authStatus.textContent = messages[AUTH_OUTCOMES[result.status] ?? 'authFailed'] ?? '';
942
921
  }
943
922
  }
944
923
 
924
+ /** A sign-in request that did not come back: the page's own deadline elapsing is said to be a timeout. */
925
+ function reportAuthRequestFailure(error) {
926
+ authStatus.textContent = messages[error?.message === REQUEST_TIMED_OUT ? 'authTimedOut' : 'authFailed'] ?? '';
927
+ recordActiveUiEventBestEffort('request-failed');
928
+ }
929
+
945
930
  authForm.addEventListener('submit', async (event) => {
946
931
  event.preventDefault();
947
932
  const existing = pluginConfig.find((block) => block.platform === 'HomebridgeEufy') ?? {};
@@ -970,9 +955,8 @@ authForm.addEventListener('submit', async (event) => {
970
955
  setBusy(true);
971
956
  try {
972
957
  await handleResult(await requestWithinDeadline('/auth/start', { configuration: pendingConfig }));
973
- } catch {
974
- authStatus.textContent = messages.authFailed ?? '';
975
- recordActiveUiEventBestEffort('request-failed');
958
+ } catch (error) {
959
+ reportAuthRequestFailure(error);
976
960
  } finally {
977
961
  setBusy(false);
978
962
  }
@@ -986,9 +970,8 @@ challengeForm.addEventListener('submit', async (event) => {
986
970
  setBusy(true);
987
971
  try {
988
972
  await handleResult(await requestWithinDeadline(path, body));
989
- } catch {
990
- authStatus.textContent = messages.authFailed ?? '';
991
- recordActiveUiEventBestEffort('request-failed');
973
+ } catch (error) {
974
+ reportAuthRequestFailure(error);
992
975
  } finally {
993
976
  setBusy(false);
994
977
  }