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