@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.16 → 5.0.0-beta.17

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.
@@ -24,6 +24,11 @@ const dashboardSummary = document.querySelector('[data-dashboard-summary]');
24
24
  const dashboardAuthenticate = document.querySelector('[data-dashboard-authenticate]');
25
25
  const reauthDialog = document.querySelector('[data-reauth-dialog]');
26
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]');
27
32
  const deviceGroups = document.querySelector('[data-device-groups]');
28
33
  const updatePending = document.querySelector('[data-update-pending]');
29
34
  const updatePendingSummary = document.querySelector('[data-update-pending-summary]');
@@ -40,42 +45,27 @@ const diagnosticsPanel = document.querySelector('[data-diagnostics]');
40
45
  const mastheadDiagnostics = document.querySelector('[data-masthead-diagnostics]');
41
46
  const diagnosticsClose = document.querySelector('[data-diagnostics-close]');
42
47
  const diagnosticsWizardPanel = document.querySelector('[data-diagnostics-wizard]');
43
- const diagnosticsQuestion = document.querySelector('[data-diagnostics-question]');
44
48
  const diagnosticsQuestionText = document.querySelector('[data-diagnostics-question-text]');
45
49
  const diagnosticsTiles = [...document.querySelectorAll('[data-diagnostics-tile]')];
46
- const diagnosticsDevices = document.querySelector('[data-diagnostics-devices]');
47
- const diagnosticsDeviceList = document.querySelector('[data-diagnostics-device-list]');
48
- const diagnosticsDevicesEvery = document.querySelector('[data-diagnostics-devices-every]');
49
- const diagnosticsDevicesChosen = document.querySelector('[data-diagnostics-devices-chosen]');
50
- const diagnosticsFrequency = document.querySelector('[data-diagnostics-frequency]');
51
- const diagnosticsFrequencyHeading = document.querySelector('#diagnostics-frequency-heading');
52
- const diagnosticsFrequencyNow = document.querySelector('[data-diagnostics-frequency-answer="now"]');
53
- const diagnosticsFrequencyIntermittent = document.querySelector('[data-diagnostics-frequency-answer="intermittent"]');
54
- const diagnosticsFrequencyBack = document.querySelector('[data-diagnostics-frequency-back]');
55
- const diagnosticsMatch = document.querySelector('[data-diagnostics-match]');
56
- const diagnosticsReject = document.querySelector('[data-diagnostics-reject]');
57
- const diagnosticsAuthorize = document.querySelector('[data-diagnostics-authorize]');
50
+ const diagnosticsCancel = document.querySelector('[data-diagnostics-cancel]');
58
51
  const diagnosticsReproduction = document.querySelector('[data-diagnostics-reproduction]');
59
52
  const diagnosticsStatus = document.querySelector('[data-diagnostics-status]');
60
53
  const diagnosticsIssue = document.querySelector('[data-diagnostics-issue]');
61
- const diagnosticsIssueHint = document.querySelector('[data-diagnostics-issue-hint]');
54
+ const diagnosticsExistingIssue = document.querySelector('[data-diagnostics-existing-issue]');
62
55
  const diagnosticsResult = document.querySelector('[data-diagnostics-result]');
63
56
  const diagnosticsActions = document.querySelector('[data-diagnostics-actions]');
64
- const diagnosticsGuidanceTitle = document.querySelector('[data-diagnostics-guidance-title]');
65
- const diagnosticsModeSummary = document.querySelector('[data-diagnostics-mode-summary]');
66
57
  const diagnosticsGuidance = document.querySelector('[data-diagnostics-guidance]');
67
58
  const diagnosticsPhaseTitle = document.querySelector('[data-diagnostics-phase-title]');
68
- const diagnosticsGuidanceBeforeSection = document.querySelector('[data-diagnostics-guidance-before-section]');
69
- const diagnosticsGuidanceBefore = document.querySelector('[data-diagnostics-guidance-before]');
70
59
  const diagnosticsGuidanceAction = document.querySelector('[data-diagnostics-guidance-action]');
71
- const diagnosticsManifest = document.querySelector('[data-diagnostics-manifest]');
72
- const diagnosticsReviewConfirm = document.querySelector('[data-diagnostics-review-confirm]');
73
- 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]');
74
64
  const diagnosticsExport = document.querySelector('[data-diagnostics-export]');
75
65
  const diagnosticsResultHeading = document.querySelector('[data-diagnostics-result-heading]');
76
66
  const diagnosticsStartAnother = document.querySelector('[data-diagnostics-start-another]');
67
+ const diagnosticsDownloadAgain = document.querySelector('[data-diagnostics-download-again]');
77
68
  const advancedPanel = document.querySelector('[data-advanced-settings]');
78
- const dashboardDiagnose = document.querySelector('[data-dashboard-diagnose]');
79
69
  const devicePanel = document.querySelector('[data-device-settings]');
80
70
  const deviceClose = document.querySelector('[data-device-close]');
81
71
  const deviceSettingsTitle = document.querySelector('[data-device-settings-title]');
@@ -103,22 +93,21 @@ let legacyAcknowledged = false;
103
93
  let diagnosticsState = { status: 'inactive', missingEvidence: [] };
104
94
  /** Whether the reporter already downloaded this session's archive, which is what the report offer follows. */
105
95
  let diagnosticsArchiveDownloaded = false;
106
- /** The session whose manifest is on screen, so it is fetched once rather than on every render. */
96
+ /** The session whose archive is reviewed and ready to export, so the review is fetched once rather than per render. */
107
97
  let diagnosticsReviewedCaseId = '';
108
- /** Guards the one fetch, so a burst of renders does not assemble the manifest several times over. */
109
- let diagnosticsReviewPending = false;
98
+ /** The one fetch in flight, so a burst of renders does not assemble the archive several times over. */
99
+ let diagnosticsReviewRequest;
110
100
  let diagnosticsReviewId = '';
111
- let diagnosticsStartingAnother = false;
112
101
  let panelReturn;
102
+ /** The problem the dashboard last drew, which the dashboard's diagnostics action points at while there is one. */
103
+ let dashboardAttention;
113
104
  let dashboardPanelTrigger;
114
105
  const dashboardView = window.HomebridgeEufyDashboard;
115
106
  const legacySettingsView = window.HomebridgeEufyLegacySettings;
116
107
  const diagnosticsWizard = window.HomebridgeEufyDiagnosticsWizard;
117
- let diagnosticsWizardState = diagnosticsWizard.start();
118
108
  const dashboardElements = {
119
109
  dashboard,
120
110
  title: dashboardTitle,
121
- diagnose: dashboardDiagnose,
122
111
  summary: dashboardSummary,
123
112
  authenticate: dashboardAuthenticate,
124
113
  groups: deviceGroups,
@@ -136,12 +125,17 @@ const dashboardElements = {
136
125
 
137
126
  const REQUEST_TIMED_OUT = 'Request timed out';
138
127
 
128
+ /**
129
+ * A request the page stops waiting for after `timeoutMs`. The timeout error carries the request as `late`, since
130
+ * the plugin may still carry it out after the page gave up.
131
+ */
139
132
  function requestWithinDeadline(path, body, timeoutMs = 320000) {
140
133
  let timer;
134
+ const request = homebridge.request(path, body);
141
135
  const timeout = new Promise((_, reject) => {
142
- timer = setTimeout(() => reject(new Error(REQUEST_TIMED_OUT)), timeoutMs);
136
+ timer = setTimeout(() => reject(Object.assign(new Error(REQUEST_TIMED_OUT), { late: request })), timeoutMs);
143
137
  });
144
- return Promise.race([homebridge.request(path, body), timeout]).finally(() => clearTimeout(timer));
138
+ return Promise.race([request, timeout]).finally(() => clearTimeout(timer));
145
139
  }
146
140
 
147
141
  function isDashboardUiReproducing(state = diagnosticsState) {
@@ -220,144 +214,21 @@ function setBusy(busy) {
220
214
  }
221
215
 
222
216
  const diagnosticsProfiles = {
223
- 'startup-authentication': {
224
- title: 'diagnosticsProfileStartup',
225
- summary: 'diagnosticsStartupSummary',
226
- before: 'diagnosticsStartupBefore',
227
- action: 'diagnosticsStartupAction',
228
- },
229
- 'device-representation': {
230
- title: 'diagnosticsProfileDevices',
231
- summary: 'diagnosticsDevicesSummary',
232
- before: 'diagnosticsDevicesBefore',
233
- action: 'diagnosticsDevicesAction',
234
- },
235
- 'control-state': {
236
- title: 'diagnosticsProfileControl',
237
- summary: 'diagnosticsControlSummary',
238
- before: 'diagnosticsControlBefore',
239
- action: 'diagnosticsControlAction',
240
- },
241
- 'live-media': {
242
- title: 'diagnosticsProfileLiveMedia',
243
- summary: 'diagnosticsLiveSummary',
244
- before: 'diagnosticsLiveBefore',
245
- action: 'diagnosticsLiveAction',
246
- },
247
- 'hksv-recording': {
248
- title: 'diagnosticsProfileRecording',
249
- summary: 'diagnosticsRecordingSummary',
250
- before: 'diagnosticsRecordingBefore',
251
- action: 'diagnosticsRecordingAction',
252
- },
253
- 'dashboard-ui': {
254
- title: 'diagnosticsProfileDashboard',
255
- summary: 'diagnosticsDashboardSummary',
256
- before: 'diagnosticsDashboardBefore',
257
- action: 'diagnosticsDashboardAction',
258
- },
259
- other: {
260
- title: 'diagnosticsProfileOther',
261
- summary: 'diagnosticsOtherSummary',
262
- before: 'diagnosticsOtherBefore',
263
- action: 'diagnosticsOtherAction',
264
- },
217
+ 'startup-authentication': { title: 'diagnosticsProfileStartup', action: 'diagnosticsStartupAction' },
218
+ 'device-representation': { title: 'diagnosticsProfileDevices', action: 'diagnosticsDevicesAction' },
219
+ 'control-state': { title: 'diagnosticsProfileControl', action: 'diagnosticsControlAction' },
220
+ 'live-media': { title: 'diagnosticsProfileLiveMedia', action: 'diagnosticsLiveAction' },
221
+ 'hksv-recording': { title: 'diagnosticsProfileRecording', action: 'diagnosticsRecordingAction' },
222
+ 'dashboard-ui': { title: 'diagnosticsProfileDashboard', action: 'diagnosticsDashboardAction' },
223
+ other: { title: 'diagnosticsProfileOther', action: 'diagnosticsOtherAction' },
265
224
  };
266
225
 
267
226
  function renderDiagnosticsGuidance(profile) {
268
227
  const guidance = diagnosticsProfiles[profile] ?? diagnosticsProfiles.other;
269
- diagnosticsGuidanceTitle.textContent = messages[guidance.title] ?? '';
270
228
  diagnosticsPhaseTitle.textContent = messages[guidance.title] ?? '';
271
- diagnosticsGuidanceBefore.textContent = messages[guidance.before] ?? '';
272
229
  diagnosticsGuidanceAction.textContent = messages[guidance.action] ?? '';
273
230
  }
274
231
 
275
- /**
276
- * The devices the reporter may name, one tile each.
277
- *
278
- * A tile carries the device's own name above its retained image where it has one, and the icon the devices
279
- * view uses for a device where it does not. The image is capped at half the tile: it is there to be recognised
280
- * at a glance, and a picture that fills the tile leaves the name it belongs to competing with it.
281
- *
282
- * Only the devices the chosen area's fault could be about are offered, so the answer cannot name a device that
283
- * never had the thing being reported.
284
- *
285
- * The list is asked for when it is empty rather than relying on the devices view having been opened first, and
286
- * a request that goes unanswered leaves the step able to say every device, which is a complete answer.
287
- */
288
- async function renderDiagnosticsDeviceList() {
289
- if (dashboardDevices.length === 0) {
290
- try {
291
- const snapshot = await requestWithinDeadline('/dashboard', { representationPreferences: {} }, 12000);
292
- dashboardDevices = snapshot.devices ?? [];
293
- } catch {
294
- dashboardDevices = [];
295
- }
296
- }
297
- diagnosticsDeviceList.replaceChildren();
298
- for (const device of diagnosticsWizard.affectable(diagnosticsWizardState.profile, dashboardDevices)) {
299
- const tile = document.createElement('button');
300
- tile.type = 'button';
301
- tile.dataset.serial = device.serial;
302
- tile.setAttribute('aria-pressed', 'false');
303
- const art = document.createElement('img');
304
- art.alt = '';
305
- if (device.artwork) {
306
- art.className = 'device-photo';
307
- art.loading = 'lazy';
308
- art.src = device.artwork;
309
- } else {
310
- art.src = 'assets/icons/inventory.svg';
311
- }
312
- const name = document.createElement('span');
313
- name.textContent = device.name;
314
- tile.append(art, name);
315
- tile.addEventListener('click', () => {
316
- tile.setAttribute('aria-pressed', tile.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
317
- renderDiagnosticsDeviceChoice();
318
- });
319
- diagnosticsDeviceList.append(tile);
320
- }
321
- renderDiagnosticsDeviceChoice();
322
- }
323
-
324
- /**
325
- * The two controls that follow the pressed tiles: whether continuing is possible, and what the escape does.
326
- *
327
- * With every tile pressed there is nothing left for a select-all to select, so the same control clears instead —
328
- * otherwise the only way back from a full grid is pressing each tile again.
329
- */
330
- function renderDiagnosticsDeviceChoice() {
331
- const tiles = diagnosticsDeviceList.querySelectorAll('[data-serial]').length;
332
- const chosen = chosenDiagnosticsDevices().length;
333
- diagnosticsDevicesChosen.disabled = chosen === 0;
334
- diagnosticsDevicesEvery.textContent =
335
- messages[chosen > 0 && chosen === tiles ? 'diagnosticsDevicesNone' : 'diagnosticsDevicesEvery'] ?? '';
336
- }
337
-
338
- /** The serials pressed in the device step, in the order the grid drew them. */
339
- function chosenDiagnosticsDevices() {
340
- return [...diagnosticsDeviceList.querySelectorAll('[data-serial]')]
341
- .filter((tile) => tile.getAttribute('aria-pressed') === 'true')
342
- .map((tile) => tile.dataset.serial);
343
- }
344
-
345
- function renderDiagnosticsWizard() {
346
- diagnosticsQuestion.hidden = diagnosticsWizardState.mode !== 'tiles';
347
- diagnosticsDevices.hidden = diagnosticsWizardState.mode !== 'devices';
348
- diagnosticsFrequency.hidden = diagnosticsWizardState.mode !== 'frequency';
349
- diagnosticsMatch.hidden = diagnosticsWizardState.mode !== 'match';
350
- if (diagnosticsWizardState.mode === 'match') {
351
- renderDiagnosticsGuidance(diagnosticsWizardState.profile);
352
- diagnosticsModeSummary.textContent =
353
- messages[
354
- diagnosticsWizardState.reproductionMode === 'intermittent'
355
- ? 'diagnosticsModeIntermittentSummary'
356
- : 'diagnosticsModeNowSummary'
357
- ] ?? '';
358
- }
359
- }
360
-
361
232
  /**
362
233
  * The report step, reachable or not.
363
234
  *
@@ -367,175 +238,126 @@ function renderDiagnosticsWizard() {
367
238
  */
368
239
  function setIssueStepReachable(url) {
369
240
  diagnosticsIssue.href = url;
370
- diagnosticsIssue.setAttribute('aria-disabled', url ? 'false' : 'true');
371
- diagnosticsIssueHint.textContent = messages[url ? 'diagnosticsIssueOpensTab' : 'diagnosticsIssueNeedsArchive'] ?? '';
241
+ diagnosticsExistingIssue.href = url.replace(/\/issues\/new.*$/, '/issues?q=is%3Aissue+involves%3A%40me');
242
+ }
243
+
244
+ /**
245
+ * Leaves the archive dialog once its file is handed over, which is what ends the session: the plugin keeps it until
246
+ * then so a download that never arrived can be tried again.
247
+ */
248
+ async function endDiagnosticsCase() {
249
+ try {
250
+ renderDiagnostics(await requestWithinDeadline('/diagnostics/cancel', undefined, 12000));
251
+ } catch (error) {
252
+ await redrawDiagnosticsAfterFailure('inactive', error);
253
+ }
254
+ diagnosticsQuestionText.focus?.();
372
255
  }
373
256
 
374
257
  function renderDiagnostics(state) {
375
258
  if (state.supportCaseId !== diagnosticsState.supportCaseId) diagnosticsArchiveDownloaded = false;
376
259
  diagnosticsState = state;
377
- const screen = diagnosticsWizard.screen(state, diagnosticsStartingAnother);
260
+ const screen = diagnosticsWizard.screen(state);
378
261
  const choosing = screen === 'choose';
379
262
  const reviewing = screen === 'review';
380
- const reproductionMode = state.reproductionMode ?? 'now';
381
- const collecting = isDashboardUiReproducing(state);
382
- for (const entry of [mastheadDiagnostics, menuDiagnostics]) {
383
- if (collecting) entry.dataset.collecting = 'true';
384
- else delete entry.dataset.collecting;
385
- entry.setAttribute(
386
- 'aria-label',
387
- messages[collecting ? 'diagnosticsCollectingFinishHere' : 'menuDiagnostics'] ?? '',
388
- );
389
- }
263
+ renderDiagnosticsEntries();
390
264
  diagnosticsWizardPanel.hidden = !choosing;
391
265
  diagnosticsGuidance.hidden = screen !== 'reproduce';
392
- diagnosticsGuidanceBeforeSection.hidden = state.status === 'reproducing';
393
266
  diagnosticsActions.hidden = screen !== 'reproduce';
394
- diagnosticsReproduction.disabled = !['authorized', 'reproducing'].includes(state.status);
395
- diagnosticsReproduction.textContent =
396
- reproductionMode === 'intermittent'
397
- ? state.status === 'reproducing'
398
- ? messages.diagnosticsIntermittentIssueHappened
399
- : messages.diagnosticsIntermittentStartWaiting
400
- : state.status === 'reproducing'
401
- ? messages.diagnosticsNowFinish
402
- : messages.diagnosticsStartRecording;
403
- diagnosticsIssue.hidden = true;
267
+ diagnosticsReproduction.disabled = state.status !== 'reproducing';
404
268
  setIssueStepReachable(diagnosticsArchiveDownloaded ? (state.issueUrl ?? '') : '');
405
- diagnosticsResult.hidden = !reviewing;
269
+ const offering = (reviewing || diagnosticsArchiveDownloaded) && !diagnosticsPanel.hidden;
270
+ if (offering && !diagnosticsResult.open) diagnosticsResult.showModal?.();
271
+ if (!offering && diagnosticsResult.open) diagnosticsResult.close?.();
272
+ diagnosticsResultHeading.textContent =
273
+ messages[
274
+ diagnosticsArchiveDownloaded
275
+ ? 'diagnosticsArchiveDownloaded'
276
+ : state.reproductionEndedAt && state.reproductionEndedAt === state.expiresAt
277
+ ? 'diagnosticsCaptureTimedOut'
278
+ : 'diagnosticsEvidenceReady'
279
+ ] ?? '';
406
280
  const reviewed = reviewing && diagnosticsReviewedCaseId === (state.supportCaseId ?? '');
407
- diagnosticsManifest.hidden = !reviewed;
408
- diagnosticsReviewConfirmLabel.hidden = !reviewed;
409
- diagnosticsExport.hidden = !reviewed;
410
- diagnosticsIssue.hidden = !reviewed;
281
+ diagnosticsExport.hidden = !reviewed || diagnosticsArchiveDownloaded;
282
+ diagnosticsHandoff.hidden = !diagnosticsArchiveDownloaded;
283
+ diagnosticsHandoffNote.hidden = !diagnosticsArchiveDownloaded;
284
+ if (!offering) diagnosticsResultStatus.textContent = '';
285
+ else if (!diagnosticsArchiveDownloaded && state.missingEvidence?.length) {
286
+ diagnosticsResultStatus.textContent = messages.diagnosticsMissingEvidence ?? '';
287
+ }
288
+ diagnosticsStartAnother.hidden = !diagnosticsArchiveDownloaded;
289
+ diagnosticsDownloadAgain.hidden = !diagnosticsArchiveDownloaded;
411
290
  if (!reviewed) {
412
- diagnosticsReviewConfirm.checked = false;
413
291
  diagnosticsExport.disabled = true;
414
292
  diagnosticsReviewId = '';
415
- if (reviewing) void ensureArchiveReview(state.supportCaseId ?? '');
293
+ if (reviewing && !diagnosticsArchiveDownloaded) void ensureArchiveReview(state.supportCaseId ?? '');
416
294
  }
417
295
  if (screen === 'reproduce') {
418
296
  renderDiagnosticsGuidance(state.profile);
419
- if (reproductionMode === 'intermittent') {
420
- diagnosticsGuidanceBeforeSection.hidden = true;
421
- diagnosticsGuidanceAction.textContent =
422
- messages[
423
- state.status === 'reproducing'
424
- ? 'diagnosticsIntermittentWaitingGuidance'
425
- : 'diagnosticsIntermittentReadyGuidance'
426
- ] ?? '';
427
- }
297
+ const stopsAt = new Date(state.expiresAt).toLocaleString(shell.lang || undefined, {
298
+ weekday: 'long',
299
+ hour: 'numeric',
300
+ minute: '2-digit',
301
+ });
302
+ diagnosticsCaptureNote.textContent = (messages.diagnosticsCaptureKeepsRunning ?? '').replace('{time}', stopsAt);
428
303
  }
429
- if (choosing) renderDiagnosticsWizard();
430
- const statusKey = reviewing
431
- ? state.missingEvidence?.length
432
- ? 'diagnosticsMissingEvidence'
433
- : 'diagnosticsComplete'
434
- : {
435
- inactive: 'diagnosticsInactive',
436
- authorized:
437
- reproductionMode === 'intermittent' ? 'diagnosticsIntermittentAuthorized' : 'diagnosticsAuthorized',
438
- reproducing:
439
- reproductionMode === 'intermittent' ? 'diagnosticsIntermittentReproducing' : 'diagnosticsReproducing',
440
- complete: state.missingEvidence?.length ? 'diagnosticsMissingEvidence' : 'diagnosticsComplete',
441
- expired: 'diagnosticsExpired',
442
- }[state.status];
443
- diagnosticsStatus.textContent = (
444
- choosing && (state.status === 'inactive' || diagnosticsStartingAnother) ? '' : (messages[statusKey] ?? '')
445
- ).replace('{evidence}', state.missingEvidence?.join(', ') ?? '');
304
+ diagnosticsStatus.textContent = '';
446
305
  }
447
306
 
448
307
  /**
449
- * The catalogue entry that says, in a reader's words, what a never-collected class is.
450
- *
451
- * A class with no entry falls back to its own identifier, which is visible rather than absent, so a newly
452
- * declared class shows up as something to translate instead of quietly disappearing from the list.
308
+ * Marks both diagnostics actions while a capture runs, whichever area it is on, and otherwise the dashboard's own
309
+ * while it shows a problem.
453
310
  */
454
- const NEVER_COLLECTED_LABELS = {
455
- 'credentials-and-authentication': 'excludedCredentials',
456
- 'tokens-cookies-and-authorization': 'excludedTokens',
457
- 'session-and-push-stores': 'excludedSessions',
458
- 'private-and-symmetric-keys': 'excludedKeys',
459
- 'unconstrained-internal-objects': 'excludedInternalData',
460
- 'camera-images-talkback-and-raw-media': 'excludedMedia',
461
- };
462
-
463
- function renderArchiveManifest(manifest) {
464
- diagnosticsManifest.replaceChildren();
465
- const summary = document.createElement('p');
466
- const mode =
467
- messages[manifest.reproductionMode === 'intermittent' ? 'diagnosticsModeIntermittent' : 'diagnosticsModeNow'] ??
468
- manifest.reproductionMode;
469
- summary.textContent = `${manifest.archiveFormat} v${manifest.version} · ${manifest.keyId} · ${messages.diagnosticsArchiveMode ?? ''} ${mode} · ${(messages.diagnosticsArchiveExpires ?? '').replace('{expiresAt}', new Date(manifest.archiveExpiresAt).toLocaleString(shell.lang || 'en'))}`;
470
- const evidence = document.createElement('ul');
471
- for (const item of manifest.evidence ?? []) {
472
- const row = document.createElement('li');
473
- const size = item.bytes === undefined ? '' : ` · ${item.bytes} B`;
474
- const truncated = item.truncated ? ` · ${messages.diagnosticsArchiveTruncated ?? ''}` : '';
475
- const missing = item.missingReason ? ` · ${item.missingReason}` : '';
476
- const fields = (item.fields ?? []).map((field) => `${field.field}: ${field.privacyClass}`).join(', ');
477
- row.textContent = `${item.evidence} · ${item.privacyClass} · ${item.status}${missing}${size}${truncated}${fields ? ` · ${messages.diagnosticsArchiveFields ?? ''} ${fields}` : ''}`;
478
- evidence.append(row);
479
- }
480
- const detail = document.createElement('details');
481
- const detailLabel = document.createElement('summary');
482
- detailLabel.textContent = messages.diagnosticsArchiveDetail ?? '';
483
- detail.append(detailLabel, summary, evidence);
484
- const exclusions = document.createElement('p');
485
- const neverCollected = (manifest.excludedClasses ?? []).map(
486
- (excluded) => messages[NEVER_COLLECTED_LABELS[excluded]] ?? excluded,
487
- );
488
- exclusions.textContent = `${messages.diagnosticsArchiveExcluded ?? ''} ${neverCollected.join(', ')}.`;
489
- diagnosticsManifest.append(detail, exclusions);
490
- const uncovered = (manifest.evidence ?? []).filter((item) => item.coversReproduction === false);
491
- if (uncovered.length > 0) {
492
- const gap = document.createElement('p');
493
- gap.textContent = (messages.diagnosticsArchiveCoverageGap ?? '')
494
- .replace('{classes}', uncovered.map((item) => item.evidence).join(', '))
495
- .replace(
496
- '{retainedFrom}',
497
- new Date(
498
- Math.min(...uncovered.map((item) => Date.parse(item.retainedFrom))),
499
- ).toLocaleString(shell.lang || 'en'),
500
- );
501
- diagnosticsManifest.append(gap);
311
+ function renderDiagnosticsEntries() {
312
+ const collecting = diagnosticsState.status === 'reproducing';
313
+ for (const entry of [mastheadDiagnostics, menuDiagnostics]) {
314
+ const attending = !collecting && entry === menuDiagnostics && dashboardAttention !== undefined;
315
+ if (collecting) entry.dataset.collecting = 'true';
316
+ else delete entry.dataset.collecting;
317
+ if (attending) entry.dataset.attention = 'true';
318
+ else delete entry.dataset.attention;
319
+ entry.setAttribute(
320
+ 'aria-label',
321
+ messages[collecting ? 'diagnosticsCollectingFinishHere' : attending ? 'diagnosticsAttention' : 'menuDiagnostics'] ??
322
+ '',
323
+ );
502
324
  }
503
- diagnosticsManifest.hidden = false;
504
325
  }
505
326
 
506
327
  /**
507
- * The manifest for the session on screen, fetched once.
328
+ * The reviewed archive for the session on screen, fetched once.
508
329
  *
509
- * A completed reproduction that the reporter is looking at is a file they came for, so the manifest is
510
- * read without being asked for. It is read once per session rather than per render, because assembling it
511
- * reads every collected log. Exporting spends it, so the session is forgotten again and a second download
512
- * asks for a fresh one.
330
+ * A completed reproduction that the reporter is looking at is a file they came for, so the archive is
331
+ * reviewed without being asked for. It is reviewed once per session rather than per render, because
332
+ * assembling it reads every collected log. Exporting spends it, and ends the session with it.
513
333
  */
514
- async function ensureArchiveReview(caseId) {
515
- if (!caseId || diagnosticsReviewedCaseId === caseId || diagnosticsReviewPending) return;
516
- diagnosticsReviewPending = true;
517
- try {
518
- const review = await requestWithinDeadline('/diagnostics/archive/review', undefined, 12000);
519
- diagnosticsReviewId = review.reviewId;
520
- diagnosticsReviewedCaseId = caseId;
521
- renderArchiveManifest(review.manifest);
522
- diagnosticsReviewConfirmLabel.hidden = false;
523
- diagnosticsExport.hidden = false;
524
- diagnosticsExport.disabled = !diagnosticsReviewConfirm.checked;
525
- diagnosticsIssue.hidden = false;
526
- } catch {
527
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
528
- } finally {
529
- diagnosticsReviewPending = false;
530
- }
334
+ function ensureArchiveReview(caseId) {
335
+ if (!caseId || diagnosticsReviewedCaseId === caseId) return Promise.resolve();
336
+ diagnosticsReviewRequest ??= (async () => {
337
+ try {
338
+ const review = await requestWithinDeadline('/diagnostics/archive/review', undefined, 12000);
339
+ diagnosticsReviewId = review.reviewId;
340
+ diagnosticsReviewedCaseId = caseId;
341
+ diagnosticsExport.hidden = false;
342
+ diagnosticsExport.disabled = false;
343
+ } catch {
344
+ diagnosticsResultStatus.textContent = messages.diagnosticsFailed ?? '';
345
+ } finally {
346
+ diagnosticsReviewRequest = undefined;
347
+ }
348
+ })();
349
+ return diagnosticsReviewRequest;
531
350
  }
532
351
 
533
- diagnosticsReviewConfirm.addEventListener('change', () => {
534
- diagnosticsExport.disabled = !diagnosticsReviewConfirm.checked || !diagnosticsReviewId;
535
- });
536
-
537
- diagnosticsExport.addEventListener('click', async () => {
538
- if (!diagnosticsReviewConfirm.checked || !diagnosticsReviewId) return;
352
+ /**
353
+ * Hands the archive of the session on screen over as a download.
354
+ *
355
+ * Finishing a reproduction calls this without being asked, since the file is what the reporter finished for; the
356
+ * dialog's own button is the way to try again after a failed attempt.
357
+ */
358
+ async function downloadDiagnosticsArchive() {
359
+ await ensureArchiveReview(diagnosticsState.supportCaseId ?? '');
360
+ if (!diagnosticsReviewId) return;
539
361
  diagnosticsExport.disabled = true;
540
362
  try {
541
363
  const exported = await requestWithinDeadline(
@@ -546,140 +368,119 @@ diagnosticsExport.addEventListener('click', async () => {
546
368
  const download = document.createElement('a');
547
369
  download.href = `data:${exported.mediaType};base64,${exported.archive}`;
548
370
  download.download = exported.filename;
371
+ diagnosticsHandoffNote.textContent = (messages.diagnosticsArchiveHandoff ?? '').replace(
372
+ '{filename}',
373
+ exported.filename,
374
+ );
375
+ diagnosticsResultStatus.textContent = '';
549
376
  document.body.appendChild(download);
550
377
  download.click();
551
378
  document.body.removeChild(download);
552
379
  diagnosticsReviewId = '';
553
380
  diagnosticsReviewedCaseId = '';
554
- diagnosticsReviewConfirm.checked = false;
555
381
  diagnosticsArchiveDownloaded = true;
556
- setIssueStepReachable(diagnosticsState.issueUrl ?? '');
382
+ renderDiagnostics(diagnosticsState);
383
+ diagnosticsResultHeading.focus?.();
557
384
  } catch {
558
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
385
+ // The plugin spends a review on any attempt, so trying again asks for a fresh one.
386
+ diagnosticsReviewId = '';
387
+ diagnosticsReviewedCaseId = '';
388
+ diagnosticsExport.disabled = false;
389
+ diagnosticsResultStatus.textContent = messages.diagnosticsFailed ?? '';
559
390
  }
560
- });
391
+ }
561
392
 
562
- diagnosticsAuthorize.addEventListener('click', async () => {
563
- diagnosticsAuthorize.disabled = true;
564
- let focusAuthorizeAfter = false;
565
- const previousStatus = diagnosticsState.status;
566
- try {
567
- const authorized = await requestWithinDeadline(
568
- '/diagnostics/authorize',
569
- {
570
- profile: diagnosticsWizardState.profile,
571
- reproductionMode: diagnosticsWizardState.reproductionMode,
572
- ...(diagnosticsWizardState.devices === undefined ? {} : { affectedDevices: diagnosticsWizardState.devices }),
573
- },
574
- 12000,
575
- );
576
- diagnosticsStartingAnother = false;
577
- renderDiagnostics(authorized);
578
- if (diagnosticsState.status === 'authorized') diagnosticsGuidance.focus?.();
579
- } catch {
580
- try {
581
- const refreshed = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
582
- if (refreshed.status !== previousStatus) diagnosticsStartingAnother = false;
583
- renderDiagnostics(refreshed);
584
- if (refreshed.status === previousStatus) {
585
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
586
- focusAuthorizeAfter = true;
587
- } else if (refreshed.status === 'authorized') diagnosticsGuidance.focus?.();
588
- } catch {
589
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
590
- }
591
- } finally {
592
- diagnosticsAuthorize.disabled = false;
593
- if (focusAuthorizeAfter) diagnosticsAuthorize.focus?.();
393
+ diagnosticsExport.addEventListener('click', downloadDiagnosticsArchive);
394
+ diagnosticsDownloadAgain.addEventListener('click', downloadDiagnosticsArchive);
395
+
396
+ /**
397
+ * Draws the session as the plugin holds it after `error`, and says so unless it reached `expected`, inside the
398
+ * download dialog while that covers the panel. A request that timed out may still land, so its late answer draws
399
+ * the session again.
400
+ */
401
+ async function redrawDiagnosticsAfterFailure(expected, error) {
402
+ const refreshed = await settleDiagnostics().catch(() => undefined);
403
+ if (refreshed?.status !== expected) {
404
+ (diagnosticsResult.open ? diagnosticsResultStatus : diagnosticsStatus).textContent = messages.diagnosticsFailed ?? '';
594
405
  }
595
- });
406
+ error?.late?.then(settleDiagnostics).catch(() => undefined);
407
+ }
596
408
 
597
- for (const tile of diagnosticsTiles) {
598
- tile.addEventListener('click', async () => {
599
- diagnosticsWizardState = diagnosticsWizard.select(diagnosticsWizardState, tile.dataset.diagnosticsTile);
600
- renderDiagnosticsWizard();
601
- if (diagnosticsWizardState.mode === 'devices') await renderDiagnosticsDeviceList();
602
- else diagnosticsFrequencyHeading.focus?.();
603
- });
409
+ /**
410
+ * Draws the session the plugin holds. One opened but never started is what a failed or late pick leaves behind;
411
+ * nothing on screen stands for it and it keeps detailed logging on, so it is cancelled rather than drawn.
412
+ */
413
+ async function settleDiagnostics() {
414
+ let state = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
415
+ if (state.status === 'authorized') state = await requestWithinDeadline('/diagnostics/cancel', undefined, 12000);
416
+ renderDiagnostics(state);
417
+ return state;
604
418
  }
605
419
 
606
- diagnosticsDevicesEvery.addEventListener('click', () => {
607
- const tiles = [...diagnosticsDeviceList.querySelectorAll('[data-serial]')];
608
- if (tiles.length === 0) {
609
- diagnosticsWizardState = diagnosticsWizard.chooseDevices(diagnosticsWizardState, 'all');
610
- renderDiagnosticsWizard();
611
- diagnosticsFrequencyHeading.focus?.();
612
- return;
420
+ /**
421
+ * Captures the area picked. The area is the only answer a capture needs, so the pick opens the session and starts
422
+ * it in one go.
423
+ */
424
+ async function startDiagnosticsCapture(profile) {
425
+ for (const tile of diagnosticsTiles) tile.disabled = true;
426
+ try {
427
+ await requestWithinDeadline('/diagnostics/authorize', { profile }, 12000);
428
+ const state = await requestWithinDeadline('/diagnostics/reproduction/start', undefined, 12000);
429
+ renderDiagnostics(state);
430
+ if (isDashboardUiReproducing(state)) {
431
+ await recordActiveUiEvent('background-started').catch(() => undefined);
432
+ closeDashboardPanel();
433
+ return;
434
+ }
435
+ diagnosticsPhaseTitle.focus?.();
436
+ } catch (error) {
437
+ await redrawDiagnosticsAfterFailure('reproducing', error);
438
+ } finally {
439
+ for (const tile of diagnosticsTiles) tile.disabled = false;
613
440
  }
614
- const clearing = chosenDiagnosticsDevices().length === tiles.length;
615
- for (const tile of tiles) tile.setAttribute('aria-pressed', clearing ? 'false' : 'true');
616
- renderDiagnosticsDeviceChoice();
617
- });
618
-
619
- diagnosticsDevicesChosen.addEventListener('click', () => {
620
- diagnosticsWizardState = diagnosticsWizard.chooseDevices(diagnosticsWizardState, chosenDiagnosticsDevices());
621
- renderDiagnosticsWizard();
622
- diagnosticsFrequencyHeading.focus?.();
623
- });
624
-
625
- function chooseDiagnosticsReproductionMode(reproductionMode) {
626
- diagnosticsWizardState = diagnosticsWizard.chooseReproductionMode(diagnosticsWizardState, reproductionMode);
627
- renderDiagnosticsWizard();
628
- diagnosticsGuidanceTitle.focus?.();
629
441
  }
630
442
 
631
- diagnosticsFrequencyNow.addEventListener('click', () => chooseDiagnosticsReproductionMode('now'));
632
- diagnosticsFrequencyIntermittent.addEventListener('click', () => chooseDiagnosticsReproductionMode('intermittent'));
633
-
634
- diagnosticsFrequencyBack.addEventListener('click', () => {
635
- diagnosticsWizardState = diagnosticsWizard.backFromFrequency(diagnosticsWizardState);
636
- renderDiagnosticsWizard();
637
- diagnosticsQuestionText.focus?.();
638
- });
639
-
640
- diagnosticsReject.addEventListener('click', () => {
641
- diagnosticsWizardState = diagnosticsWizard.reject(diagnosticsWizardState);
642
- renderDiagnosticsWizard();
643
- diagnosticsQuestionText.focus?.();
644
- });
443
+ for (const tile of diagnosticsTiles) {
444
+ tile.addEventListener('click', () => startDiagnosticsCapture(tile.dataset.diagnosticsTile));
445
+ }
645
446
 
646
- diagnosticsStartAnother.addEventListener('click', () => {
647
- diagnosticsStartingAnother = true;
648
- diagnosticsWizardState = diagnosticsWizard.start();
649
- renderDiagnostics(diagnosticsState);
650
- diagnosticsQuestionText.focus?.();
447
+ diagnosticsStartAnother.addEventListener('click', endDiagnosticsCase);
448
+ /** Escape leaves the archive dialog only once its file is downloaded, and leaving it finishes the session. */
449
+ diagnosticsResult.addEventListener('cancel', (event) => {
450
+ event.preventDefault();
451
+ if (diagnosticsArchiveDownloaded) endDiagnosticsCase();
651
452
  });
652
453
 
653
454
  diagnosticsReproduction.addEventListener('click', async () => {
654
455
  diagnosticsReproduction.disabled = true;
655
- const previousStatus = diagnosticsState.status;
656
- const path =
657
- diagnosticsState.status === 'reproducing' ? '/diagnostics/reproduction/end' : '/diagnostics/reproduction/start';
658
456
  try {
659
- let state = await requestWithinDeadline(path, undefined, 12000);
457
+ let state = await requestWithinDeadline('/diagnostics/reproduction/end', undefined, 12000);
660
458
  if (state.status === 'complete' && state.missingEvidence?.length) {
661
459
  await new Promise((resolve) => setTimeout(resolve, 250));
662
460
  state = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
663
461
  }
664
462
  renderDiagnostics(state);
665
- if (previousStatus === 'authorized' && isDashboardUiReproducing(state)) {
666
- await recordActiveUiEvent('background-started').catch(() => undefined);
667
- closeDashboardPanel();
668
- return;
669
- }
670
- if (state.status === 'complete' || state.partialExportAvailable) diagnosticsResultHeading.focus?.();
671
- } catch {
672
- try {
673
- const refreshed = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
674
- renderDiagnostics(refreshed);
675
- if (refreshed.status === previousStatus) diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
676
- if (refreshed.status === 'complete' || refreshed.partialExportAvailable) diagnosticsResultHeading.focus?.();
677
- else if (refreshed.status === 'authorized' || refreshed.status === 'reproducing') diagnosticsGuidance.focus?.();
678
- } catch {
679
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
680
- }
463
+ } catch (error) {
464
+ await redrawDiagnosticsAfterFailure('complete', error);
465
+ } finally {
466
+ diagnosticsReproduction.disabled = diagnosticsState.status !== 'reproducing';
467
+ }
468
+ if (diagnosticsState.partialExportAvailable) {
469
+ diagnosticsResultHeading.focus?.();
470
+ await downloadDiagnosticsArchive();
471
+ }
472
+ });
473
+
474
+ /** Cancelling a capture deletes its session without a file, and puts the areas back on the panel. */
475
+ diagnosticsCancel.addEventListener('click', async () => {
476
+ diagnosticsCancel.disabled = true;
477
+ try {
478
+ renderDiagnostics(await requestWithinDeadline('/diagnostics/cancel', undefined, 12000));
479
+ diagnosticsQuestionText.focus?.();
480
+ } catch (error) {
481
+ await redrawDiagnosticsAfterFailure('inactive', error);
681
482
  } finally {
682
- diagnosticsReproduction.disabled = !['authorized', 'reproducing'].includes(diagnosticsState.status);
483
+ diagnosticsCancel.disabled = false;
683
484
  }
684
485
  });
685
486
 
@@ -736,29 +537,46 @@ function closeDashboardPanel() {
736
537
  }
737
538
  }
738
539
 
739
- mastheadDiagnostics.addEventListener('click', () => menuDiagnostics.click());
740
-
741
- // The dashboard's own offer of a run goes the same way the menu does, so there is one path into the wizard.
742
- dashboardDiagnose.addEventListener('click', () => menuDiagnostics.click());
743
-
744
- menuDiagnostics.addEventListener('click', async () => {
745
- /*
746
- * Opened from the sign-in screen, the area is not in question: what is going wrong is getting signed in. The
747
- * answer is presumed rather than fixed, so the frequency step's way back still reaches the other six.
748
- */
540
+ /**
541
+ * Opens the diagnostics panel. A preset is an area the reporter already chose to diagnose from a problem the
542
+ * dashboard named beside the privacy note, so a panel with nothing under way captures it at once. Opened over the
543
+ * sign-in screen, the panel asks like any other and puts focus on the startup area.
544
+ */
545
+ async function openDiagnostics(preset) {
749
546
  const signingIn = !setupContent.hidden;
750
547
  openDashboardPanel(diagnosticsPanel, menuDiagnostics);
751
- if (signingIn) {
752
- diagnosticsWizardState = diagnosticsWizard.select(diagnosticsWizard.start(), 'startup-authentication');
753
- }
754
- // Drawn on opening, because the wizard's first question is only ever put on the page by this call.
755
- renderDiagnosticsWizard();
756
548
  try {
757
549
  renderDiagnostics(await requestWithinDeadline('/diagnostics/status', undefined, 12000));
758
550
  } catch {
759
551
  diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
760
552
  recordActiveUiEventBestEffort('request-failed');
553
+ return;
554
+ }
555
+ if (diagnosticsWizard.screen(diagnosticsState) !== 'choose') return;
556
+ if (preset) await startDiagnosticsCapture(preset.profile);
557
+ else if (signingIn) {
558
+ diagnosticsTiles.find((tile) => tile.dataset.diagnosticsTile === 'startup-authentication')?.focus?.();
761
559
  }
560
+ }
561
+
562
+ mastheadDiagnostics.addEventListener('click', () => openDiagnostics());
563
+
564
+ /** While the dashboard shows a problem and no session is under way, the action names it before opening the wizard. */
565
+ menuDiagnostics.addEventListener('click', () => {
566
+ if (!menuDiagnostics.dataset.attention || diagnosticsWizard.screen(diagnosticsState) !== 'choose') {
567
+ return openDiagnostics();
568
+ }
569
+ attentionTitle.textContent = dashboardAttention.title;
570
+ attentionSummary.textContent = dashboardAttention.summary;
571
+ attentionDialog.showModal?.();
572
+ });
573
+ attentionDiagnose.addEventListener('click', () => {
574
+ attentionDialog.close?.();
575
+ return openDiagnostics(dashboardAttention);
576
+ });
577
+ attentionChoose.addEventListener('click', () => {
578
+ attentionDialog.close?.();
579
+ return openDiagnostics();
762
580
  });
763
581
  menuAdvanced.addEventListener('click', async () => {
764
582
  const config = configuredBlock() ?? {};
@@ -785,9 +603,8 @@ menuAdvanced.addEventListener('click', async () => {
785
603
  }
786
604
  });
787
605
  diagnosticsClose.addEventListener('click', () => {
788
- diagnosticsStartingAnother = false;
789
- renderDiagnostics(diagnosticsState);
790
606
  closeDashboardPanel();
607
+ renderDiagnostics(diagnosticsState);
791
608
  });
792
609
  advancedClose.addEventListener('click', () => {
793
610
  closeDashboardPanel();
@@ -1041,7 +858,7 @@ async function showDashboard() {
1041
858
  warmUpCandidates = Array.isArray(snapshot.warmUpCandidates) ? snapshot.warmUpCandidates : [];
1042
859
  // Kept beside the render that drew them, so opening a tile finds the device that tile stands for.
1043
860
  dashboardDevices = snapshot.devices ?? [];
1044
- dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
861
+ dashboardAttention = dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
1045
862
  if (snapshot.state === 'authentication-required' && snapshot.devices?.length > 0 && !reauthDialog.open) {
1046
863
  reauthDialog.showModal?.();
1047
864
  }
@@ -1050,9 +867,15 @@ async function showDashboard() {
1050
867
  .applyDeviceImages(dashboardElements, (serial) => requestWithinDeadline('/device/image', { serial }, 12000))
1051
868
  .catch(() => undefined);
1052
869
  } catch {
1053
- dashboardView.render({ state: 'unreachable', devices: [] }, configuredBlock() ?? {}, messages, dashboardElements);
870
+ dashboardAttention = dashboardView.render(
871
+ { state: 'unreachable', devices: [] },
872
+ configuredBlock() ?? {},
873
+ messages,
874
+ dashboardElements,
875
+ );
1054
876
  recordActiveUiEventBestEffort('request-failed');
1055
877
  }
878
+ renderDiagnosticsEntries();
1056
879
  recordActiveUiEventBestEffort('dashboard-opened');
1057
880
  }
1058
881