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

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 +350 -358
  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'));
@@ -838,7 +807,7 @@ dashboardView.bindPreferences(dashboardElements, configuredBlock, updateConfig,
838
807
  openDashboardPanel(devicePanel, trigger);
839
808
  });
840
809
 
841
- dashboardAuthenticate.addEventListener('click', () => {
810
+ function openAuthentication() {
842
811
  diagnosticsPanel.hidden = true;
843
812
  advancedPanel.hidden = true;
844
813
  dashboard.hidden = true;
@@ -847,6 +816,16 @@ dashboardAuthenticate.addEventListener('click', () => {
847
816
  pageTitle.textContent = messages.pageTitle;
848
817
  accountInput.focus?.();
849
818
  recordActiveUiEventBestEffort('authentication-opened');
819
+ }
820
+
821
+ dashboardAuthenticate.addEventListener('click', openAuthentication);
822
+
823
+ // A saved session the plugin can no longer use stops every device, so nothing on the page works until the user
824
+ // signs in again. The dialog says so in front of everything else, and Escape does not dismiss it.
825
+ reauthDialog.addEventListener('cancel', (event) => event.preventDefault());
826
+ reauthAction.addEventListener('click', () => {
827
+ reauthDialog.close?.();
828
+ openAuthentication();
850
829
  });
851
830
 
852
831
  legacyAcknowledge.addEventListener('click', async () => {
@@ -856,12 +835,19 @@ legacyAcknowledge.addEventListener('click', async () => {
856
835
  try {
857
836
  await updateConfig({ ...existing, discardedV4Settings: legacyNames, discardedV4Acknowledged: true });
858
837
  } catch {
838
+ legacyStatus.textContent = messages.preferenceSaveFailed ?? '';
859
839
  return;
860
840
  }
861
841
  }
862
842
  legacyNotice.hidden = true;
863
843
  });
864
844
 
845
+ /**
846
+ * Writes the signed-in account into the Homebridge configuration, reporting whether it was saved.
847
+ *
848
+ * The Save button is enabled before the attempt and never disabled by it, so a write that fails leaves the user
849
+ * the one control that retries it.
850
+ */
865
851
  async function saveAuthenticatedConfig() {
866
852
  homebridge.enableSaveButton();
867
853
  try {
@@ -870,7 +856,9 @@ async function saveAuthenticatedConfig() {
870
856
  pluginConfig = [pendingConfig];
871
857
  savedConfigSignature = configSignature(pluginConfig);
872
858
  homebridge.enableSaveButton();
859
+ return true;
873
860
  } catch {
861
+ return false;
874
862
  } finally {
875
863
  passwordInput.value = '';
876
864
  pendingConfig = undefined;
@@ -890,18 +878,35 @@ async function showDashboard() {
890
878
  warmUpCandidates = Array.isArray(snapshot.warmUpCandidates) ? snapshot.warmUpCandidates : [];
891
879
  // Kept beside the render that drew them, so opening a tile finds the device that tile stands for.
892
880
  dashboardDevices = snapshot.devices ?? [];
893
- dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
881
+ dashboardAttention = dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
882
+ if (snapshot.state === 'authentication-required' && snapshot.devices?.length > 0 && !reauthDialog.open) {
883
+ reauthDialog.showModal?.();
884
+ }
894
885
  // The image pass runs beside the rendered dashboard, so its failure is dropped here rather than escaping.
895
886
  void dashboardView
896
887
  .applyDeviceImages(dashboardElements, (serial) => requestWithinDeadline('/device/image', { serial }, 12000))
897
888
  .catch(() => undefined);
898
889
  } catch {
899
- dashboardView.render({ state: 'missing', devices: [] }, configuredBlock() ?? {}, messages, dashboardElements);
890
+ dashboardAttention = dashboardView.render(
891
+ { state: 'unreachable', devices: [] },
892
+ configuredBlock() ?? {},
893
+ messages,
894
+ dashboardElements,
895
+ );
900
896
  recordActiveUiEventBestEffort('request-failed');
901
897
  }
898
+ renderDiagnosticsEntries();
902
899
  recordActiveUiEventBestEffort('dashboard-opened');
903
900
  }
904
901
 
902
+ /** The message each unsuccessful sign-in outcome is stated with; any other outcome is a failed sign-in. */
903
+ const AUTH_OUTCOMES = {
904
+ blocked: 'dashboardOwnerConflictSummary',
905
+ 'plugin-running': 'authPluginRunning',
906
+ 'commit-failed': 'authCommitFailed',
907
+ 'timed-out': 'authTimedOut',
908
+ };
909
+
905
910
  async function handleResult(result) {
906
911
  if (result.status === 'captcha') {
907
912
  challenge = 'captcha';
@@ -910,7 +915,7 @@ async function handleResult(result) {
910
915
  challengeImage.hidden = false;
911
916
  challengeLabel.textContent = messages.captchaLabel ?? '';
912
917
  challengeForm.hidden = false;
913
- authStatus.textContent = '';
918
+ authStatus.textContent = result.retry ? (messages.captchaRetry ?? '') : '';
914
919
  return;
915
920
  }
916
921
  if (result.status === 'two-factor') {
@@ -919,7 +924,7 @@ async function handleResult(result) {
919
924
  challengeImage.hidden = true;
920
925
  challengeLabel.textContent = messages.twoFactorLabel ?? '';
921
926
  challengeForm.hidden = false;
922
- authStatus.textContent = result.method;
927
+ authStatus.textContent = messages.twoFactorSent ?? '';
923
928
  return;
924
929
  }
925
930
 
@@ -927,38 +932,27 @@ async function handleResult(result) {
927
932
  if (result.status === 'restart-required') {
928
933
  authForm.hidden = true;
929
934
  authStatus.textContent = messages.authSuccess ?? '';
930
- await saveAuthenticatedConfig();
931
935
  // 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 ?? '';
936
+ if (await saveAuthenticatedConfig()) await showDashboard();
937
+ else authStatus.textContent = messages.authSaveFailed ?? '';
939
938
  } else {
940
939
  authForm.hidden = false;
941
- authStatus.textContent = messages.authFailed ?? '';
940
+ authStatus.textContent = messages[AUTH_OUTCOMES[result.status] ?? 'authFailed'] ?? '';
942
941
  }
943
942
  }
944
943
 
944
+ /** A sign-in request that did not come back: the page's own deadline elapsing is said to be a timeout. */
945
+ function reportAuthRequestFailure(error) {
946
+ authStatus.textContent = messages[error?.message === REQUEST_TIMED_OUT ? 'authTimedOut' : 'authFailed'] ?? '';
947
+ recordActiveUiEventBestEffort('request-failed');
948
+ }
949
+
945
950
  authForm.addEventListener('submit', async (event) => {
946
951
  event.preventDefault();
952
+ // A sign-in replaces the account and nothing else: every other key of the block, the child bridge included, is kept.
947
953
  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
954
  pendingConfig = {
961
- ...retained,
955
+ ...existing,
962
956
  platform: 'HomebridgeEufy',
963
957
  username: accountInput.value.trim(),
964
958
  password: passwordInput.value,
@@ -970,9 +964,8 @@ authForm.addEventListener('submit', async (event) => {
970
964
  setBusy(true);
971
965
  try {
972
966
  await handleResult(await requestWithinDeadline('/auth/start', { configuration: pendingConfig }));
973
- } catch {
974
- authStatus.textContent = messages.authFailed ?? '';
975
- recordActiveUiEventBestEffort('request-failed');
967
+ } catch (error) {
968
+ reportAuthRequestFailure(error);
976
969
  } finally {
977
970
  setBusy(false);
978
971
  }
@@ -986,9 +979,8 @@ challengeForm.addEventListener('submit', async (event) => {
986
979
  setBusy(true);
987
980
  try {
988
981
  await handleResult(await requestWithinDeadline(path, body));
989
- } catch {
990
- authStatus.textContent = messages.authFailed ?? '';
991
- recordActiveUiEventBestEffort('request-failed');
982
+ } catch (error) {
983
+ reportAuthRequestFailure(error);
992
984
  } finally {
993
985
  setBusy(false);
994
986
  }