@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.3 → 5.0.0-beta.31

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 (88) hide show
  1. package/config.schema.json +48 -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 +44 -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/device/snapshot.js +1 -1
  9. package/dist/device/snapshot.js.map +1 -1
  10. package/dist/diagnostics.js +694 -83
  11. package/dist/diagnostics.js.map +1 -1
  12. package/dist/homekit/adapters/camera-streaming.js +56 -27
  13. package/dist/homekit/adapters/camera-streaming.js.map +1 -1
  14. package/dist/homekit/adapters/lock.js +45 -16
  15. package/dist/homekit/adapters/lock.js.map +1 -1
  16. package/dist/homekit/adapters/motion.js +1 -1
  17. package/dist/homekit/adapters/motion.js.map +1 -1
  18. package/dist/homekit/adapters/package-sensor.js +63 -0
  19. package/dist/homekit/adapters/package-sensor.js.map +1 -0
  20. package/dist/homekit/adapters/registry.js +2 -0
  21. package/dist/homekit/adapters/registry.js.map +1 -1
  22. package/dist/homekit/adapters/security-system.js +101 -45
  23. package/dist/homekit/adapters/security-system.js.map +1 -1
  24. package/dist/homekit/coverage-matrix.js +1 -3
  25. package/dist/homekit/coverage-matrix.js.map +1 -1
  26. package/dist/homekit/reconciler.js +21 -5
  27. package/dist/homekit/reconciler.js.map +1 -1
  28. package/dist/media/last-successful-image.js +5 -3
  29. package/dist/media/last-successful-image.js.map +1 -1
  30. package/dist/media/live-stream.js +200 -84
  31. package/dist/media/live-stream.js.map +1 -1
  32. package/dist/media/recording.js +11 -5
  33. package/dist/media/recording.js.map +1 -1
  34. package/dist/media/snapshot.js +21 -8
  35. package/dist/media/snapshot.js.map +1 -1
  36. package/dist/media/station-live-sessions.js +47 -43
  37. package/dist/media/station-live-sessions.js.map +1 -1
  38. package/dist/platform.js +27 -7
  39. package/dist/platform.js.map +1 -1
  40. package/dist/runtime/channel.js +116 -4
  41. package/dist/runtime/channel.js.map +1 -1
  42. package/dist/runtime/device-settings.js +88 -0
  43. package/dist/runtime/device-settings.js.map +1 -0
  44. package/dist/runtime/owner.js +12 -0
  45. package/dist/runtime/owner.js.map +1 -1
  46. package/dist/runtime/sdk-client.js +19 -4
  47. package/dist/runtime/sdk-client.js.map +1 -1
  48. package/dist/ui/dashboard.js +32 -14
  49. package/dist/ui/dashboard.js.map +1 -1
  50. package/dist/ui/runtime-channel-client.js +64 -1
  51. package/dist/ui/runtime-channel-client.js.map +1 -1
  52. package/dist/ui/server.js +64 -5
  53. package/dist/ui/server.js.map +1 -1
  54. package/homebridge-ui/public/app.css +359 -88
  55. package/homebridge-ui/public/assets/icons/battery_0.svg +1 -0
  56. package/homebridge-ui/public/assets/icons/battery_1.svg +1 -0
  57. package/homebridge-ui/public/assets/icons/battery_2.svg +1 -0
  58. package/homebridge-ui/public/assets/icons/battery_3.svg +1 -0
  59. package/homebridge-ui/public/assets/icons/battery_4.svg +1 -0
  60. package/homebridge-ui/public/assets/icons/battery_5.svg +1 -0
  61. package/homebridge-ui/public/assets/icons/battery_6.svg +1 -0
  62. package/homebridge-ui/public/assets/icons/battery_full.svg +1 -0
  63. package/homebridge-ui/public/assets/icons/contact_support.svg +1 -0
  64. package/homebridge-ui/public/assets/icons/delete.svg +1 -0
  65. package/homebridge-ui/public/assets/icons/download.svg +1 -0
  66. package/homebridge-ui/public/assets/icons/extension_off.svg +1 -0
  67. package/homebridge-ui/public/assets/icons/info.svg +1 -0
  68. package/homebridge-ui/public/assets/icons/key_off.svg +1 -0
  69. package/homebridge-ui/public/assets/icons/keyboard_arrow_left.svg +1 -0
  70. package/homebridge-ui/public/assets/icons/keyboard_arrow_right.svg +1 -0
  71. package/homebridge-ui/public/assets/icons/keyboard_double_arrow_left.svg +1 -0
  72. package/homebridge-ui/public/assets/icons/keyboard_double_arrow_right.svg +1 -0
  73. package/homebridge-ui/public/assets/icons/movie_off.svg +1 -0
  74. package/homebridge-ui/public/assets/icons/refresh.svg +1 -0
  75. package/homebridge-ui/public/assets/icons/satellite_alt.svg +1 -0
  76. package/homebridge-ui/public/assets/icons/search_off.svg +1 -0
  77. package/homebridge-ui/public/assets/icons/sensors_off.svg +1 -0
  78. package/homebridge-ui/public/assets/icons/settings_photo_camera.svg +1 -0
  79. package/homebridge-ui/public/assets/icons/solar_power.svg +1 -0
  80. package/homebridge-ui/public/assets/icons/videocam_off.svg +1 -0
  81. package/homebridge-ui/public/i18n/en.json +119 -78
  82. package/homebridge-ui/public/i18n/fr.json +119 -78
  83. package/homebridge-ui/public/index.html +115 -106
  84. package/homebridge-ui/public/js/app.js +354 -359
  85. package/homebridge-ui/public/js/dashboard.js +139 -10
  86. package/homebridge-ui/public/js/profile-wizard.js +17 -54
  87. package/i18n/runtime/en.json +8 -3
  88. package/package.json +9 -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]');
@@ -84,6 +75,10 @@ const advancedClose = document.querySelector('[data-advanced-close]');
84
75
  const advancedPolling = document.querySelector('[data-advanced-polling]');
85
76
  const advancedConcurrentMedia = document.querySelector('[data-advanced-concurrent-media]');
86
77
  const advancedFfmpeg = document.querySelector('[data-advanced-ffmpeg]');
78
+ const advancedSmallVideoPackets = document.querySelector('[data-advanced-small-video-packets]');
79
+ const advancedLiveVideoPassthrough = document.querySelector('[data-advanced-live-video-passthrough]');
80
+ const advancedHdLiveVideo = document.querySelector('[data-advanced-hd-live-video]');
81
+ const advancedIgnoreHomeBitRate = document.querySelector('[data-advanced-ignore-home-bit-rate]');
87
82
  const warmUpAvailable = document.querySelector('[data-warm-up-available]');
88
83
  const warmUpChosen = document.querySelector('[data-warm-up-chosen]');
89
84
  const warmUpAdd = document.querySelector('[data-warm-up-add]');
@@ -100,18 +95,23 @@ let challenge = '';
100
95
  let legacyNames = [];
101
96
  let legacyAcknowledged = false;
102
97
  let diagnosticsState = { status: 'inactive', missingEvidence: [] };
98
+ /** Whether the reporter already downloaded this session's archive, which is what the report offer follows. */
99
+ let diagnosticsArchiveDownloaded = false;
100
+ /** The session whose archive is reviewed and ready to export, so the review is fetched once rather than per render. */
101
+ let diagnosticsReviewedCaseId = '';
102
+ /** The one fetch in flight, so a burst of renders does not assemble the archive several times over. */
103
+ let diagnosticsReviewRequest;
103
104
  let diagnosticsReviewId = '';
104
- let diagnosticsStartingAnother = false;
105
105
  let panelReturn;
106
+ /** The problem the dashboard last drew, which the dashboard's diagnostics action points at while there is one. */
107
+ let dashboardAttention;
106
108
  let dashboardPanelTrigger;
107
109
  const dashboardView = window.HomebridgeEufyDashboard;
108
110
  const legacySettingsView = window.HomebridgeEufyLegacySettings;
109
111
  const diagnosticsWizard = window.HomebridgeEufyDiagnosticsWizard;
110
- let diagnosticsWizardState = diagnosticsWizard.start();
111
112
  const dashboardElements = {
112
113
  dashboard,
113
114
  title: dashboardTitle,
114
- diagnose: dashboardDiagnose,
115
115
  summary: dashboardSummary,
116
116
  authenticate: dashboardAuthenticate,
117
117
  groups: deviceGroups,
@@ -127,12 +127,19 @@ const dashboardElements = {
127
127
  masthead,
128
128
  };
129
129
 
130
+ const REQUEST_TIMED_OUT = 'Request timed out';
131
+
132
+ /**
133
+ * A request the page stops waiting for after `timeoutMs`. The timeout error carries the request as `late`, since
134
+ * the plugin may still carry it out after the page gave up.
135
+ */
130
136
  function requestWithinDeadline(path, body, timeoutMs = 320000) {
131
137
  let timer;
138
+ const request = homebridge.request(path, body);
132
139
  const timeout = new Promise((_, reject) => {
133
- timer = setTimeout(() => reject(new Error('Request timed out')), timeoutMs);
140
+ timer = setTimeout(() => reject(Object.assign(new Error(REQUEST_TIMED_OUT), { late: request })), timeoutMs);
134
141
  });
135
- return Promise.race([homebridge.request(path, body), timeout]).finally(() => clearTimeout(timer));
142
+ return Promise.race([request, timeout]).finally(() => clearTimeout(timer));
136
143
  }
137
144
 
138
145
  function isDashboardUiReproducing(state = diagnosticsState) {
@@ -148,6 +155,11 @@ function recordActiveUiEventBestEffort(event) {
148
155
  void recordActiveUiEvent(event).catch(() => undefined);
149
156
  }
150
157
 
158
+ /** A country code that is not two letters is refused where it is typed, in the page's own words. */
159
+ countryInput.addEventListener('input', () => {
160
+ countryInput.setCustomValidity(countryInput.validity.patternMismatch ? (messages.countryInvalid ?? '') : '');
161
+ });
162
+
151
163
  acknowledgement.addEventListener('change', () => {
152
164
  continueButton.disabled = !acknowledgement.checked;
153
165
  });
@@ -206,211 +218,150 @@ function setBusy(busy) {
206
218
  }
207
219
 
208
220
  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
- },
221
+ 'startup-authentication': { title: 'diagnosticsProfileStartup', action: 'diagnosticsStartupAction' },
222
+ 'device-representation': { title: 'diagnosticsProfileDevices', action: 'diagnosticsDevicesAction' },
223
+ 'control-state': { title: 'diagnosticsProfileControl', action: 'diagnosticsControlAction' },
224
+ 'live-media': { title: 'diagnosticsProfileLiveMedia', action: 'diagnosticsLiveAction' },
225
+ 'hksv-recording': { title: 'diagnosticsProfileRecording', action: 'diagnosticsRecordingAction' },
226
+ 'dashboard-ui': { title: 'diagnosticsProfileDashboard', action: 'diagnosticsDashboardAction' },
227
+ other: { title: 'diagnosticsProfileOther', action: 'diagnosticsOtherAction' },
251
228
  };
252
229
 
253
230
  function renderDiagnosticsGuidance(profile) {
254
231
  const guidance = diagnosticsProfiles[profile] ?? diagnosticsProfiles.other;
255
- diagnosticsGuidanceTitle.textContent = messages[guidance.title] ?? '';
256
232
  diagnosticsPhaseTitle.textContent = messages[guidance.title] ?? '';
257
- diagnosticsGuidanceBefore.textContent = messages[guidance.before] ?? '';
258
233
  diagnosticsGuidanceAction.textContent = messages[guidance.action] ?? '';
259
234
  }
260
235
 
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
- ] ?? '';
236
+ /**
237
+ * The report step, reachable or not.
238
+ *
239
+ * Without an archive there is nothing to attach, so the anchor carries no `href` — it is not a link — and
240
+ * `aria-disabled` states the same thing to a reader who cannot see it greyed. The hint says why, because a
241
+ * control that cannot be used and does not explain itself reads as broken rather than as not yet.
242
+ */
243
+ function setIssueStepReachable(url) {
244
+ diagnosticsIssue.href = url;
245
+ diagnosticsExistingIssue.href = url.replace(/\/issues\/new.*$/, '/issues?q=is%3Aissue+involves%3A%40me');
246
+ }
247
+
248
+ /**
249
+ * Leaves the archive dialog once its file is handed over, which is what ends the session: the plugin keeps it until
250
+ * then so a download that never arrived can be tried again.
251
+ */
252
+ async function endDiagnosticsCase() {
253
+ try {
254
+ renderDiagnostics(await requestWithinDeadline('/diagnostics/cancel', undefined, 12000));
255
+ } catch (error) {
256
+ await redrawDiagnosticsAfterFailure('inactive', error);
279
257
  }
258
+ diagnosticsQuestionText.focus?.();
280
259
  }
281
260
 
282
261
  function renderDiagnostics(state) {
262
+ if (state.supportCaseId !== diagnosticsState.supportCaseId) diagnosticsArchiveDownloaded = false;
283
263
  diagnosticsState = state;
284
- const screen = diagnosticsWizard.screen(state, diagnosticsStartingAnother);
264
+ const screen = diagnosticsWizard.screen(state);
285
265
  const choosing = screen === 'choose';
286
266
  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
- }
267
+ renderDiagnosticsEntries();
297
268
  diagnosticsWizardPanel.hidden = !choosing;
298
269
  diagnosticsGuidance.hidden = screen !== 'reproduce';
299
- diagnosticsGuidanceBeforeSection.hidden = state.status === 'reproducing';
300
270
  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;
271
+ diagnosticsReproduction.disabled = state.status !== 'reproducing';
272
+ setIssueStepReachable(diagnosticsArchiveDownloaded ? (state.issueUrl ?? '') : '');
273
+ const offering = (reviewing || diagnosticsArchiveDownloaded) && !diagnosticsPanel.hidden;
274
+ if (offering && !diagnosticsResult.open) diagnosticsResult.showModal?.();
275
+ if (!offering && diagnosticsResult.open) diagnosticsResult.close?.();
276
+ diagnosticsResultHeading.textContent =
277
+ messages[
278
+ diagnosticsArchiveDownloaded
279
+ ? 'diagnosticsArchiveDownloaded'
280
+ : state.reproductionEndedAt && state.reproductionEndedAt === state.expiresAt
281
+ ? 'diagnosticsCaptureTimedOut'
282
+ : 'diagnosticsEvidenceReady'
283
+ ] ?? '';
284
+ const reviewed = reviewing && diagnosticsReviewedCaseId === (state.supportCaseId ?? '');
285
+ diagnosticsExport.hidden = !reviewed || diagnosticsArchiveDownloaded;
286
+ diagnosticsHandoff.hidden = !diagnosticsArchiveDownloaded;
287
+ diagnosticsHandoffNote.hidden = !diagnosticsArchiveDownloaded;
288
+ if (!offering) diagnosticsResultStatus.textContent = '';
289
+ else if (!diagnosticsArchiveDownloaded && state.missingEvidence?.length) {
290
+ diagnosticsResultStatus.textContent = messages.diagnosticsMissingEvidence ?? '';
291
+ }
292
+ diagnosticsStartAnother.hidden = !diagnosticsArchiveDownloaded;
293
+ diagnosticsDownloadAgain.hidden = !diagnosticsArchiveDownloaded;
294
+ if (!reviewed) {
295
+ diagnosticsExport.disabled = true;
296
+ diagnosticsReviewId = '';
297
+ if (reviewing && !diagnosticsArchiveDownloaded) void ensureArchiveReview(state.supportCaseId ?? '');
298
+ }
321
299
  if (screen === 'reproduce') {
322
300
  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
- }
301
+ const stopsAt = new Date(state.expiresAt).toLocaleString(shell.lang || undefined, {
302
+ weekday: 'long',
303
+ hour: 'numeric',
304
+ minute: '2-digit',
305
+ });
306
+ diagnosticsCaptureNote.textContent = (messages.diagnosticsCaptureKeepsRunning ?? '').replace('{time}', stopsAt);
332
307
  }
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(', ') ?? '');
308
+ diagnosticsStatus.textContent = '';
350
309
  }
351
310
 
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);
311
+ /**
312
+ * Marks both diagnostics actions while a capture runs, whichever area it is on, and otherwise the dashboard's own
313
+ * while it shows a problem.
314
+ */
315
+ function renderDiagnosticsEntries() {
316
+ const collecting = diagnosticsState.status === 'reproducing';
317
+ for (const entry of [mastheadDiagnostics, menuDiagnostics]) {
318
+ const attending = !collecting && entry === menuDiagnostics && dashboardAttention !== undefined;
319
+ if (collecting) entry.dataset.collecting = 'true';
320
+ else delete entry.dataset.collecting;
321
+ if (attending) entry.dataset.attention = 'true';
322
+ else delete entry.dataset.attention;
323
+ entry.setAttribute(
324
+ 'aria-label',
325
+ messages[collecting ? 'diagnosticsCollectingFinishHere' : attending ? 'diagnosticsAttention' : 'menuDiagnostics'] ??
326
+ '',
327
+ );
384
328
  }
385
- diagnosticsManifest.hidden = false;
386
329
  }
387
330
 
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 {
331
+ /**
332
+ * The reviewed archive for the session on screen, fetched once.
333
+ *
334
+ * A completed reproduction that the reporter is looking at is a file they came for, so the archive is
335
+ * reviewed without being asked for. It is reviewed once per session rather than per render, because
336
+ * assembling it reads every collected log. Exporting spends it, and ends the session with it.
337
+ */
338
+ function ensureArchiveReview(caseId) {
339
+ if (!caseId || diagnosticsReviewedCaseId === caseId) return Promise.resolve();
340
+ diagnosticsReviewRequest ??= (async () => {
397
341
  try {
398
- renderDiagnostics(await requestWithinDeadline('/diagnostics/status', undefined, 12000));
399
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
342
+ const review = await requestWithinDeadline('/diagnostics/archive/review', undefined, 12000);
343
+ diagnosticsReviewId = review.reviewId;
344
+ diagnosticsReviewedCaseId = caseId;
345
+ diagnosticsExport.hidden = false;
346
+ diagnosticsExport.disabled = false;
400
347
  } catch {
401
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
348
+ diagnosticsResultStatus.textContent = messages.diagnosticsFailed ?? '';
349
+ } finally {
350
+ diagnosticsReviewRequest = undefined;
402
351
  }
403
- } finally {
404
- diagnosticsReview.disabled = false;
405
- }
406
- });
407
-
408
- diagnosticsReviewConfirm.addEventListener('change', () => {
409
- diagnosticsExport.disabled = !diagnosticsReviewConfirm.checked || !diagnosticsReviewId;
410
- });
352
+ })();
353
+ return diagnosticsReviewRequest;
354
+ }
411
355
 
412
- diagnosticsExport.addEventListener('click', async () => {
413
- if (!diagnosticsReviewConfirm.checked || !diagnosticsReviewId) return;
356
+ /**
357
+ * Hands the archive of the session on screen over as a download.
358
+ *
359
+ * Finishing a reproduction calls this without being asked, since the file is what the reporter finished for; the
360
+ * dialog's own button is the way to try again after a failed attempt.
361
+ */
362
+ async function downloadDiagnosticsArchive() {
363
+ await ensureArchiveReview(diagnosticsState.supportCaseId ?? '');
364
+ if (!diagnosticsReviewId) return;
414
365
  diagnosticsExport.disabled = true;
415
366
  try {
416
367
  const exported = await requestWithinDeadline(
@@ -421,141 +372,119 @@ diagnosticsExport.addEventListener('click', async () => {
421
372
  const download = document.createElement('a');
422
373
  download.href = `data:${exported.mediaType};base64,${exported.archive}`;
423
374
  download.download = exported.filename;
375
+ diagnosticsHandoffNote.textContent = (messages.diagnosticsArchiveHandoff ?? '').replace(
376
+ '{filename}',
377
+ exported.filename,
378
+ );
379
+ diagnosticsResultStatus.textContent = '';
424
380
  document.body.appendChild(download);
425
381
  download.click();
426
382
  document.body.removeChild(download);
427
383
  diagnosticsReviewId = '';
428
- diagnosticsReviewConfirm.checked = false;
384
+ diagnosticsReviewedCaseId = '';
385
+ diagnosticsArchiveDownloaded = true;
386
+ renderDiagnostics(diagnosticsState);
387
+ diagnosticsResultHeading.focus?.();
429
388
  } catch {
430
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
389
+ // The plugin spends a review on any attempt, so trying again asks for a fresh one.
390
+ diagnosticsReviewId = '';
391
+ diagnosticsReviewedCaseId = '';
392
+ diagnosticsExport.disabled = false;
393
+ diagnosticsResultStatus.textContent = messages.diagnosticsFailed ?? '';
431
394
  }
432
- });
395
+ }
396
+
397
+ diagnosticsExport.addEventListener('click', downloadDiagnosticsArchive);
398
+ diagnosticsDownloadAgain.addEventListener('click', downloadDiagnosticsArchive);
399
+
400
+ /**
401
+ * Draws the session as the plugin holds it after `error`, and says so unless it reached `expected`, inside the
402
+ * download dialog while that covers the panel. A request that timed out may still land, so its late answer draws
403
+ * the session again.
404
+ */
405
+ async function redrawDiagnosticsAfterFailure(expected, error) {
406
+ const refreshed = await settleDiagnostics().catch(() => undefined);
407
+ if (refreshed?.status !== expected) {
408
+ (diagnosticsResult.open ? diagnosticsResultStatus : diagnosticsStatus).textContent = messages.diagnosticsFailed ?? '';
409
+ }
410
+ error?.late?.then(settleDiagnostics).catch(() => undefined);
411
+ }
412
+
413
+ /**
414
+ * Draws the session the plugin holds. One opened but never started is what a failed or late pick leaves behind;
415
+ * nothing on screen stands for it and it keeps detailed logging on, so it is cancelled rather than drawn.
416
+ */
417
+ async function settleDiagnostics() {
418
+ let state = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
419
+ if (state.status === 'authorized') state = await requestWithinDeadline('/diagnostics/cancel', undefined, 12000);
420
+ renderDiagnostics(state);
421
+ return state;
422
+ }
433
423
 
434
- diagnosticsAuthorize.addEventListener('click', async () => {
435
- diagnosticsAuthorize.disabled = true;
436
- let focusAuthorizeAfter = false;
437
- const previousStatus = diagnosticsState.status;
424
+ /**
425
+ * Captures the area picked. The area is the only answer a capture needs, so the pick opens the session and starts
426
+ * it in one go.
427
+ */
428
+ async function startDiagnosticsCapture(profile) {
429
+ for (const tile of diagnosticsTiles) tile.disabled = true;
438
430
  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 ?? '';
431
+ await requestWithinDeadline('/diagnostics/authorize', { profile }, 12000);
432
+ const state = await requestWithinDeadline('/diagnostics/reproduction/start', undefined, 12000);
433
+ renderDiagnostics(state);
434
+ if (isDashboardUiReproducing(state)) {
435
+ await recordActiveUiEvent('background-started').catch(() => undefined);
436
+ closeDashboardPanel();
437
+ return;
461
438
  }
439
+ diagnosticsPhaseTitle.focus?.();
440
+ } catch (error) {
441
+ await redrawDiagnosticsAfterFailure('reproducing', error);
462
442
  } finally {
463
- diagnosticsAuthorize.disabled = false;
464
- if (focusAuthorizeAfter) diagnosticsAuthorize.focus?.();
443
+ for (const tile of diagnosticsTiles) tile.disabled = false;
465
444
  }
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
445
  }
504
446
 
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
- });
447
+ for (const tile of diagnosticsTiles) {
448
+ tile.addEventListener('click', () => startDiagnosticsCapture(tile.dataset.diagnosticsTile));
449
+ }
521
450
 
522
- diagnosticsStartAnother.addEventListener('click', () => {
523
- diagnosticsStartingAnother = true;
524
- diagnosticsWizardState = diagnosticsWizard.start();
525
- renderDiagnostics(diagnosticsState);
526
- diagnosticsQuestionText.focus?.();
451
+ diagnosticsStartAnother.addEventListener('click', endDiagnosticsCase);
452
+ /** Escape leaves the archive dialog only once its file is downloaded, and leaving it finishes the session. */
453
+ diagnosticsResult.addEventListener('cancel', (event) => {
454
+ event.preventDefault();
455
+ if (diagnosticsArchiveDownloaded) endDiagnosticsCase();
527
456
  });
528
457
 
529
458
  diagnosticsReproduction.addEventListener('click', async () => {
530
459
  diagnosticsReproduction.disabled = true;
531
- const previousStatus = diagnosticsState.status;
532
- const path =
533
- diagnosticsState.status === 'reproducing' ? '/diagnostics/reproduction/end' : '/diagnostics/reproduction/start';
534
460
  try {
535
- let state = await requestWithinDeadline(path, undefined, 12000);
461
+ let state = await requestWithinDeadline('/diagnostics/reproduction/end', undefined, 12000);
536
462
  if (state.status === 'complete' && state.missingEvidence?.length) {
537
463
  await new Promise((resolve) => setTimeout(resolve, 250));
538
464
  state = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
539
465
  }
540
466
  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
- }
467
+ } catch (error) {
468
+ await redrawDiagnosticsAfterFailure('complete', error);
557
469
  } finally {
558
- diagnosticsReproduction.disabled = !['authorized', 'reproducing'].includes(diagnosticsState.status);
470
+ diagnosticsReproduction.disabled = diagnosticsState.status !== 'reproducing';
471
+ }
472
+ if (diagnosticsState.partialExportAvailable) {
473
+ diagnosticsResultHeading.focus?.();
474
+ await downloadDiagnosticsArchive();
475
+ }
476
+ });
477
+
478
+ /** Cancelling a capture deletes its session without a file, and puts the areas back on the panel. */
479
+ diagnosticsCancel.addEventListener('click', async () => {
480
+ diagnosticsCancel.disabled = true;
481
+ try {
482
+ renderDiagnostics(await requestWithinDeadline('/diagnostics/cancel', undefined, 12000));
483
+ diagnosticsQuestionText.focus?.();
484
+ } catch (error) {
485
+ await redrawDiagnosticsAfterFailure('inactive', error);
486
+ } finally {
487
+ diagnosticsCancel.disabled = false;
559
488
  }
560
489
  });
561
490
 
@@ -612,21 +541,46 @@ function closeDashboardPanel() {
612
541
  }
613
542
  }
614
543
 
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 () => {
544
+ /**
545
+ * Opens the diagnostics panel. A preset is an area the reporter already chose to diagnose from a problem the
546
+ * dashboard named beside the privacy note, so a panel with nothing under way captures it at once. Opened over the
547
+ * sign-in screen, the panel asks like any other and puts focus on the startup area.
548
+ */
549
+ async function openDiagnostics(preset) {
550
+ const signingIn = !setupContent.hidden;
621
551
  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
552
  try {
625
553
  renderDiagnostics(await requestWithinDeadline('/diagnostics/status', undefined, 12000));
626
554
  } catch {
627
555
  diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
628
556
  recordActiveUiEventBestEffort('request-failed');
557
+ return;
558
+ }
559
+ if (diagnosticsWizard.screen(diagnosticsState) !== 'choose') return;
560
+ if (preset) await startDiagnosticsCapture(preset.profile);
561
+ else if (signingIn) {
562
+ diagnosticsTiles.find((tile) => tile.dataset.diagnosticsTile === 'startup-authentication')?.focus?.();
563
+ }
564
+ }
565
+
566
+ mastheadDiagnostics.addEventListener('click', () => openDiagnostics());
567
+
568
+ /** While the dashboard shows a problem and no session is under way, the action names it before opening the wizard. */
569
+ menuDiagnostics.addEventListener('click', () => {
570
+ if (!menuDiagnostics.dataset.attention || diagnosticsWizard.screen(diagnosticsState) !== 'choose') {
571
+ return openDiagnostics();
629
572
  }
573
+ attentionTitle.textContent = dashboardAttention.title;
574
+ attentionSummary.textContent = dashboardAttention.summary;
575
+ attentionDialog.showModal?.();
576
+ });
577
+ attentionDiagnose.addEventListener('click', () => {
578
+ attentionDialog.close?.();
579
+ return openDiagnostics(dashboardAttention);
580
+ });
581
+ attentionChoose.addEventListener('click', () => {
582
+ attentionDialog.close?.();
583
+ return openDiagnostics();
630
584
  });
631
585
  menuAdvanced.addEventListener('click', async () => {
632
586
  const config = configuredBlock() ?? {};
@@ -636,6 +590,10 @@ menuAdvanced.addEventListener('click', async () => {
636
590
  warmUpMarked = new Set();
637
591
  renderWarmUp();
638
592
  advancedFfmpeg.value = config.ffmpegPath ?? '';
593
+ advancedSmallVideoPackets.checked = config.smallVideoPackets === true;
594
+ advancedLiveVideoPassthrough.checked = config.liveVideoPassthrough === true;
595
+ advancedHdLiveVideo.checked = config.hdLiveVideo === true;
596
+ advancedIgnoreHomeBitRate.checked = config.ignoreHomeBitRate === true;
639
597
  openDashboardPanel(advancedPanel, menuAdvanced);
640
598
  /**
641
599
  * The panel asks for its own candidates rather than relying on the devices view having been opened first.
@@ -653,9 +611,8 @@ menuAdvanced.addEventListener('click', async () => {
653
611
  }
654
612
  });
655
613
  diagnosticsClose.addEventListener('click', () => {
656
- diagnosticsStartingAnother = false;
657
- renderDiagnostics(diagnosticsState);
658
614
  closeDashboardPanel();
615
+ renderDiagnostics(diagnosticsState);
659
616
  });
660
617
  advancedClose.addEventListener('click', () => {
661
618
  closeDashboardPanel();
@@ -692,6 +649,14 @@ async function updateAdvancedSettings() {
692
649
  else next.maxConcurrentMediaSessions = maxConcurrentMediaSessions;
693
650
  if (ffmpegPath) next.ffmpegPath = ffmpegPath;
694
651
  else delete next.ffmpegPath;
652
+ if (advancedSmallVideoPackets.checked) next.smallVideoPackets = true;
653
+ else delete next.smallVideoPackets;
654
+ if (advancedLiveVideoPassthrough.checked) next.liveVideoPassthrough = true;
655
+ else delete next.liveVideoPassthrough;
656
+ if (advancedHdLiveVideo.checked) next.hdLiveVideo = true;
657
+ else delete next.hdLiveVideo;
658
+ if (advancedIgnoreHomeBitRate.checked) next.ignoreHomeBitRate = true;
659
+ else delete next.ignoreHomeBitRate;
695
660
  // The default is what the plugin applies when the key is absent, so storing it would only pin today's default.
696
661
  if (warmUpSelection.length === 1 && warmUpSelection[0] === 'doorbellPress') delete next.warmUpEvents;
697
662
  else next.warmUpEvents = [...warmUpSelection];
@@ -707,6 +672,10 @@ async function updateAdvancedSettings() {
707
672
  advancedPolling.addEventListener('change', updateAdvancedSettings);
708
673
  advancedConcurrentMedia.addEventListener('change', updateAdvancedSettings);
709
674
  advancedFfmpeg.addEventListener('change', updateAdvancedSettings);
675
+ advancedSmallVideoPackets.addEventListener('change', updateAdvancedSettings);
676
+ advancedLiveVideoPassthrough.addEventListener('change', updateAdvancedSettings);
677
+ advancedHdLiveVideo.addEventListener('change', updateAdvancedSettings);
678
+ advancedIgnoreHomeBitRate.addEventListener('change', updateAdvancedSettings);
710
679
  warmUpAdd.addEventListener('click', () => moveWarmUp('available'));
711
680
  warmUpAddAll.addEventListener('click', () => moveWarmUp('available', true));
712
681
  warmUpRemove.addEventListener('click', () => moveWarmUp('chosen'));
@@ -825,7 +794,10 @@ function configSignature(config) {
825
794
  async function updateConfig(block) {
826
795
  const nextConfig = pluginConfig.map((candidate) => (candidate.platform === 'HomebridgeEufy' ? block : candidate));
827
796
  if (!nextConfig.includes(block)) nextConfig.push(block);
828
- await homebridge.updatePluginConfig(nextConfig);
797
+ // Homebridge UI merges each block into the one it holds, so a key the block dropped is sent as undefined to clear it.
798
+ const cleared = Object.keys(configuredBlock() ?? {}).filter((key) => !(key in block));
799
+ const sent = { ...Object.fromEntries(cleared.map((key) => [key, undefined])), ...block };
800
+ await homebridge.updatePluginConfig(nextConfig.map((candidate) => (candidate === block ? sent : candidate)));
829
801
  pluginConfig = nextConfig;
830
802
  if (configSignature(nextConfig) === savedConfigSignature) homebridge.disableSaveButton();
831
803
  else homebridge.enableSaveButton();
@@ -838,7 +810,7 @@ dashboardView.bindPreferences(dashboardElements, configuredBlock, updateConfig,
838
810
  openDashboardPanel(devicePanel, trigger);
839
811
  });
840
812
 
841
- dashboardAuthenticate.addEventListener('click', () => {
813
+ function openAuthentication() {
842
814
  diagnosticsPanel.hidden = true;
843
815
  advancedPanel.hidden = true;
844
816
  dashboard.hidden = true;
@@ -847,6 +819,16 @@ dashboardAuthenticate.addEventListener('click', () => {
847
819
  pageTitle.textContent = messages.pageTitle;
848
820
  accountInput.focus?.();
849
821
  recordActiveUiEventBestEffort('authentication-opened');
822
+ }
823
+
824
+ dashboardAuthenticate.addEventListener('click', openAuthentication);
825
+
826
+ // A saved session the plugin can no longer use stops every device, so nothing on the page works until the user
827
+ // signs in again. The dialog says so in front of everything else, and Escape does not dismiss it.
828
+ reauthDialog.addEventListener('cancel', (event) => event.preventDefault());
829
+ reauthAction.addEventListener('click', () => {
830
+ reauthDialog.close?.();
831
+ openAuthentication();
850
832
  });
851
833
 
852
834
  legacyAcknowledge.addEventListener('click', async () => {
@@ -856,12 +838,19 @@ legacyAcknowledge.addEventListener('click', async () => {
856
838
  try {
857
839
  await updateConfig({ ...existing, discardedV4Settings: legacyNames, discardedV4Acknowledged: true });
858
840
  } catch {
841
+ legacyStatus.textContent = messages.preferenceSaveFailed ?? '';
859
842
  return;
860
843
  }
861
844
  }
862
845
  legacyNotice.hidden = true;
863
846
  });
864
847
 
848
+ /**
849
+ * Writes the signed-in account into the Homebridge configuration, reporting whether it was saved.
850
+ *
851
+ * The Save button is enabled before the attempt and never disabled by it, so a write that fails leaves the user
852
+ * the one control that retries it.
853
+ */
865
854
  async function saveAuthenticatedConfig() {
866
855
  homebridge.enableSaveButton();
867
856
  try {
@@ -870,7 +859,9 @@ async function saveAuthenticatedConfig() {
870
859
  pluginConfig = [pendingConfig];
871
860
  savedConfigSignature = configSignature(pluginConfig);
872
861
  homebridge.enableSaveButton();
862
+ return true;
873
863
  } catch {
864
+ return false;
874
865
  } finally {
875
866
  passwordInput.value = '';
876
867
  pendingConfig = undefined;
@@ -890,18 +881,35 @@ async function showDashboard() {
890
881
  warmUpCandidates = Array.isArray(snapshot.warmUpCandidates) ? snapshot.warmUpCandidates : [];
891
882
  // Kept beside the render that drew them, so opening a tile finds the device that tile stands for.
892
883
  dashboardDevices = snapshot.devices ?? [];
893
- dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
884
+ dashboardAttention = dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
885
+ if (snapshot.state === 'authentication-required' && snapshot.devices?.length > 0 && !reauthDialog.open) {
886
+ reauthDialog.showModal?.();
887
+ }
894
888
  // The image pass runs beside the rendered dashboard, so its failure is dropped here rather than escaping.
895
889
  void dashboardView
896
890
  .applyDeviceImages(dashboardElements, (serial) => requestWithinDeadline('/device/image', { serial }, 12000))
897
891
  .catch(() => undefined);
898
892
  } catch {
899
- dashboardView.render({ state: 'missing', devices: [] }, configuredBlock() ?? {}, messages, dashboardElements);
893
+ dashboardAttention = dashboardView.render(
894
+ { state: 'unreachable', devices: [] },
895
+ configuredBlock() ?? {},
896
+ messages,
897
+ dashboardElements,
898
+ );
900
899
  recordActiveUiEventBestEffort('request-failed');
901
900
  }
901
+ renderDiagnosticsEntries();
902
902
  recordActiveUiEventBestEffort('dashboard-opened');
903
903
  }
904
904
 
905
+ /** The message each unsuccessful sign-in outcome is stated with; any other outcome is a failed sign-in. */
906
+ const AUTH_OUTCOMES = {
907
+ blocked: 'dashboardOwnerConflictSummary',
908
+ 'plugin-running': 'authPluginRunning',
909
+ 'commit-failed': 'authCommitFailed',
910
+ 'timed-out': 'authTimedOut',
911
+ };
912
+
905
913
  async function handleResult(result) {
906
914
  if (result.status === 'captcha') {
907
915
  challenge = 'captcha';
@@ -910,7 +918,7 @@ async function handleResult(result) {
910
918
  challengeImage.hidden = false;
911
919
  challengeLabel.textContent = messages.captchaLabel ?? '';
912
920
  challengeForm.hidden = false;
913
- authStatus.textContent = '';
921
+ authStatus.textContent = result.retry ? (messages.captchaRetry ?? '') : '';
914
922
  return;
915
923
  }
916
924
  if (result.status === 'two-factor') {
@@ -919,7 +927,7 @@ async function handleResult(result) {
919
927
  challengeImage.hidden = true;
920
928
  challengeLabel.textContent = messages.twoFactorLabel ?? '';
921
929
  challengeForm.hidden = false;
922
- authStatus.textContent = result.method;
930
+ authStatus.textContent = messages.twoFactorSent ?? '';
923
931
  return;
924
932
  }
925
933
 
@@ -927,38 +935,27 @@ async function handleResult(result) {
927
935
  if (result.status === 'restart-required') {
928
936
  authForm.hidden = true;
929
937
  authStatus.textContent = messages.authSuccess ?? '';
930
- await saveAuthenticatedConfig();
931
938
  // 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 ?? '';
939
+ if (await saveAuthenticatedConfig()) await showDashboard();
940
+ else authStatus.textContent = messages.authSaveFailed ?? '';
939
941
  } else {
940
942
  authForm.hidden = false;
941
- authStatus.textContent = messages.authFailed ?? '';
943
+ authStatus.textContent = messages[AUTH_OUTCOMES[result.status] ?? 'authFailed'] ?? '';
942
944
  }
943
945
  }
944
946
 
947
+ /** A sign-in request that did not come back: the page's own deadline elapsing is said to be a timeout. */
948
+ function reportAuthRequestFailure(error) {
949
+ authStatus.textContent = messages[error?.message === REQUEST_TIMED_OUT ? 'authTimedOut' : 'authFailed'] ?? '';
950
+ recordActiveUiEventBestEffort('request-failed');
951
+ }
952
+
945
953
  authForm.addEventListener('submit', async (event) => {
946
954
  event.preventDefault();
955
+ // A sign-in replaces the account and nothing else: every other key of the block, the child bridge included, is kept.
947
956
  const existing = pluginConfig.find((block) => block.platform === 'HomebridgeEufy') ?? {};
948
- const retained = Object.fromEntries(
949
- [
950
- 'pollingIntervalMinutes',
951
- 'sessionWarmUp',
952
- 'ffmpegPath',
953
- 'entityPreferences',
954
- 'discardedV4Settings',
955
- 'discardedV4Acknowledged',
956
- ]
957
- .filter((key) => Object.hasOwn(existing, key))
958
- .map((key) => [key, existing[key]]),
959
- );
960
957
  pendingConfig = {
961
- ...retained,
958
+ ...existing,
962
959
  platform: 'HomebridgeEufy',
963
960
  username: accountInput.value.trim(),
964
961
  password: passwordInput.value,
@@ -970,9 +967,8 @@ authForm.addEventListener('submit', async (event) => {
970
967
  setBusy(true);
971
968
  try {
972
969
  await handleResult(await requestWithinDeadline('/auth/start', { configuration: pendingConfig }));
973
- } catch {
974
- authStatus.textContent = messages.authFailed ?? '';
975
- recordActiveUiEventBestEffort('request-failed');
970
+ } catch (error) {
971
+ reportAuthRequestFailure(error);
976
972
  } finally {
977
973
  setBusy(false);
978
974
  }
@@ -986,9 +982,8 @@ challengeForm.addEventListener('submit', async (event) => {
986
982
  setBusy(true);
987
983
  try {
988
984
  await handleResult(await requestWithinDeadline(path, body));
989
- } catch {
990
- authStatus.textContent = messages.authFailed ?? '';
991
- recordActiveUiEventBestEffort('request-failed');
985
+ } catch (error) {
986
+ reportAuthRequestFailure(error);
992
987
  } finally {
993
988
  setBusy(false);
994
989
  }