devicectl-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. devicectl/__init__.py +18 -0
  2. devicectl/cli/__init__.py +1 -0
  3. devicectl/cli/command.py +95 -0
  4. devicectl/cli/exits.py +32 -0
  5. devicectl/cli/fanout.py +142 -0
  6. devicectl/cli/main.py +69 -0
  7. devicectl/cli/output.py +299 -0
  8. devicectl/cli/parser.py +80 -0
  9. devicectl/cli/report.py +86 -0
  10. devicectl/cli/target.py +26 -0
  11. devicectl/clock.py +57 -0
  12. devicectl/devtools/__init__.py +6 -0
  13. devicectl/devtools/frontlint.py +935 -0
  14. devicectl/devtools/htmcheck.py +396 -0
  15. devicectl/devtools/rendercheck.py +384 -0
  16. devicectl/doctor.py +112 -0
  17. devicectl/errors.py +68 -0
  18. devicectl/fields.py +564 -0
  19. devicectl/meta.py +64 -0
  20. devicectl/paths.py +40 -0
  21. devicectl/progress.py +77 -0
  22. devicectl/report.py +67 -0
  23. devicectl/testing.py +199 -0
  24. devicectl/trace.py +333 -0
  25. devicectl/web/__init__.py +1 -0
  26. devicectl/web/agents.py +94 -0
  27. devicectl/web/events.py +171 -0
  28. devicectl/web/http.py +243 -0
  29. devicectl/web/progress.py +101 -0
  30. devicectl/web/server.py +1013 -0
  31. devicectl/web/static/core.css +3034 -0
  32. devicectl/web/static/js/api.js +198 -0
  33. devicectl/web/static/js/band.js +640 -0
  34. devicectl/web/static/js/chart.js +400 -0
  35. devicectl/web/static/js/drafts.js +312 -0
  36. devicectl/web/static/js/notify.js +272 -0
  37. devicectl/web/static/js/panels.js +432 -0
  38. devicectl/web/static/js/shell.js +672 -0
  39. devicectl/web/static/js/trace.js +133 -0
  40. devicectl/web/static/js/ui.js +1139 -0
  41. devicectl/web/static/vendor/preact-htm.module.js +27 -0
  42. devicectl/web/worker.py +697 -0
  43. devicectl_core-0.1.0.dist-info/METADATA +131 -0
  44. devicectl_core-0.1.0.dist-info/RECORD +47 -0
  45. devicectl_core-0.1.0.dist-info/WHEEL +4 -0
  46. devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
  47. devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,312 @@
1
+ /* Unsent edits: one store for the whole page, a view of it for each card,
2
+ * and the header's count of everything that is waiting.
3
+ *
4
+ * Each card used to hold its own edits in its own `useState`. That had
5
+ * three consequences that were each worth fixing and that one store fixes
6
+ * together. A card that was not on screen -- a tab switched away from, in
7
+ * a program that only builds the tab being looked at -- lost what had been
8
+ * typed into it. Two cards showing the same setting (a protection on the
9
+ * dashboard's band and in the settings table) each had their own idea of
10
+ * whether it had been changed. And the header could only ever speak for
11
+ * one page's edits, so an edit on a card with an Apply of its own was
12
+ * invisible from anywhere but that card.
13
+ *
14
+ * So edits live here, in module state, filed by *scope*: the name of the
15
+ * thing a write goes to -- a device's settings table, one endpoint of a
16
+ * charger. A card asks for a view of its scope, narrowed to the names it
17
+ * shows if it shares the scope with other cards; the view counts, clears
18
+ * and applies only those. Whoever knows how to send a scope says so once,
19
+ * with `offerWriter`, and from then on the card's tick, the header's Apply
20
+ * and anything else that asks all send it the same way.
21
+ *
22
+ * It belongs to the device, not to the tree: the program calls
23
+ * `resetDrafts` when the device changes, since an edit made for one battery
24
+ * is not an edit for the next.
25
+ */
26
+
27
+ import { Confirm } from '/core/js/ui.js';
28
+ import { html, useEffect, useState } from '/core/vendor/preact-htm.module.js';
29
+
30
+ const scopes = new Map(); // scope -> { edits, writer }
31
+ const listeners = new Set();
32
+ let applying = false;
33
+ let asking = null; // { jobs, answer } while the plan is on screen
34
+
35
+ function held(scope) {
36
+ if (!scopes.has(scope)) scopes.set(scope, { edits: {}, writer: null });
37
+ return scopes.get(scope);
38
+ }
39
+
40
+ function announce() {
41
+ for (const listener of [...listeners]) listener();
42
+ }
43
+
44
+ /* A key's field: `cellConWireRes[3]` is an element of `cellConWireRes`, and
45
+ * a card that shows the field shows every element of it. */
46
+ function field(key) {
47
+ return key.replace(/\[\d+\]$/, '');
48
+ }
49
+
50
+ function narrow(edits, names) {
51
+ if (!names) return edits;
52
+ return Object.fromEntries(Object.entries(edits).filter(([key]) => names.has(field(key))));
53
+ }
54
+
55
+ /* Drop what was sent, and only if it is still what was sent. */
56
+ function settle(scope, sent) {
57
+ const entry = held(scope);
58
+ const next = { ...entry.edits };
59
+ for (const [key, value] of Object.entries(sent)) {
60
+ if (key in next && next[key] === value) delete next[key];
61
+ }
62
+ entry.edits = next;
63
+ }
64
+
65
+ /* Re-render on any change to the store. Every view calls it; so does
66
+ * anything else that shows a count. */
67
+ export function useDrafts() {
68
+ const [, bump] = useState(0);
69
+ useEffect(() => {
70
+ const wake = () => bump((n) => n + 1);
71
+ listeners.add(wake);
72
+ return () => {
73
+ listeners.delete(wake);
74
+ };
75
+ }, []);
76
+ }
77
+
78
+ /* Forget every edit: another device. */
79
+ export function resetDrafts() {
80
+ for (const entry of scopes.values()) entry.edits = {};
81
+ asking?.answer(false);
82
+ asking = null;
83
+ announce();
84
+ }
85
+
86
+ /* How a scope is sent. Called while rendering, by whoever has what it
87
+ * takes -- the program, for a scope several tabs edit; the card itself,
88
+ * where only it does -- and the latest call wins, so `busy` and `disabled`
89
+ * are always the current ones. It is not a hook and takes nothing down
90
+ * when its caller goes away: a tab that is not on screen still has edits,
91
+ * and the header must still be able to send them.
92
+ *
93
+ * write(edits) -> a promise; the edits are dropped when it resolves.
94
+ * plan(edits) -> a promise of the changes it would make, each
95
+ * `{ name, label, oldText, newText }`: shown, and asked
96
+ * about, before `write`. Left out, `write` goes at once.
97
+ * title -> what the scope is called where several are asked
98
+ * about together.
99
+ *
100
+ * `write` and `plan` say what went wrong themselves (a toast); a rejection
101
+ * here only stops what was going to follow it. */
102
+ export function offerWriter(scope, writer) {
103
+ held(scope).writer = writer;
104
+ }
105
+
106
+ /* One card's edits: the scope's, or those of `names` among them.
107
+ *
108
+ * `get` is the whole point: a field reads through the draft and falls back
109
+ * to what the device last said, so an untouched value keeps following the
110
+ * device between polls while an edited one stays put. `apply` sends this
111
+ * view's edits the way the scope's writer says to, and `clear` drops them. */
112
+ export function useDraft(scope, { names } = {}) {
113
+ useDrafts();
114
+ const only = names ? new Set(names) : null;
115
+ const entry = held(scope);
116
+ const edits = narrow(entry.edits, only);
117
+ const count = Object.keys(edits).length;
118
+ return {
119
+ scope,
120
+ edits,
121
+ count,
122
+ dirty: count > 0,
123
+ get: (key, live) => (key in entry.edits ? entry.edits[key] : live),
124
+ has: (key) => key in entry.edits,
125
+ set: (key, value) => {
126
+ entry.edits = { ...entry.edits, [key]: value };
127
+ announce();
128
+ },
129
+ drop: (key) => {
130
+ if (!(key in entry.edits)) return;
131
+ const next = { ...entry.edits };
132
+ delete next[key];
133
+ entry.edits = next;
134
+ announce();
135
+ },
136
+ clear: () => {
137
+ const keep = only
138
+ ? Object.fromEntries(Object.entries(entry.edits).filter(([key]) => !only.has(field(key))))
139
+ : {};
140
+ entry.edits = keep;
141
+ announce();
142
+ },
143
+ apply: () => applyDrafts([{ scope, names: only }]),
144
+ /* Whether this view has somewhere to go, and whether it can go now --
145
+ * read when the card draws, after the writer has been offered. */
146
+ get writable() {
147
+ return Boolean(held(scope).writer);
148
+ },
149
+ get busy() {
150
+ return applying || Boolean(held(scope).writer?.busy);
151
+ },
152
+ get disabled() {
153
+ return Boolean(held(scope).writer?.disabled);
154
+ },
155
+ };
156
+ }
157
+
158
+ /* Everything waiting, across every scope. */
159
+ function pendingCount() {
160
+ let n = 0;
161
+ for (const entry of scopes.values()) n += Object.keys(entry.edits).length;
162
+ return n;
163
+ }
164
+
165
+ /* Send some scopes' edits: plan the ones that plan, ask once about all of
166
+ * it, then write them in turn. A write that fails stops the rest, and what
167
+ * was not written stays in the draft to be tried again. */
168
+ export async function applyDrafts(parts) {
169
+ if (applying) return;
170
+ const jobs = parts
171
+ .map(({ scope, names }) => {
172
+ const entry = held(scope);
173
+ return { scope, writer: entry.writer, edits: narrow(entry.edits, names) };
174
+ })
175
+ .filter((job) => job.writer && !job.writer.disabled && Object.keys(job.edits).length);
176
+ if (!jobs.length) return;
177
+ applying = true;
178
+ announce();
179
+ try {
180
+ if (jobs.some((job) => job.writer.plan)) {
181
+ for (const job of jobs) {
182
+ job.changes = job.writer.plan ? await job.writer.plan(job.edits) : null;
183
+ }
184
+ const yes = await new Promise((answer) => {
185
+ asking = { jobs, answer };
186
+ announce();
187
+ });
188
+ /* A value the device already holds is not an edit, whatever the
189
+ * answer was: the plan has just said so. */
190
+ for (const job of jobs) if (job.changes && !job.changes.length) settle(job.scope, job.edits);
191
+ if (!yes) return;
192
+ }
193
+ for (const job of jobs) {
194
+ if (job.changes && !job.changes.length) continue;
195
+ await job.writer.write(job.edits);
196
+ settle(job.scope, job.edits);
197
+ }
198
+ } catch {
199
+ /* The writer has said what went wrong. */
200
+ } finally {
201
+ applying = false;
202
+ asking = null;
203
+ announce();
204
+ }
205
+ }
206
+
207
+ function applyAll() {
208
+ return applyDrafts([...scopes.keys()].map((scope) => ({ scope })));
209
+ }
210
+
211
+ function discardAll() {
212
+ for (const entry of scopes.values()) entry.edits = {};
213
+ announce();
214
+ }
215
+
216
+ /* --- the header's count ---------------------------------------------------
217
+ *
218
+ * Every card with an edit says so in its own title, with its own tick and
219
+ * cross. This is the same thing for the whole page, pinned to the top of
220
+ * the window: how many edits are waiting anywhere -- on this tab or on
221
+ * one not being looked at -- a way to drop them all and a way to send them
222
+ * all. Nothing at all until something has been changed. See `.page-apply`
223
+ * in core.css for why it moves nothing else in the header. */
224
+ export function PageApply() {
225
+ useDrafts();
226
+ const count = pendingCount();
227
+ if (!count) return null;
228
+ const what = `${count} change${count === 1 ? '' : 's'}`;
229
+ const said = `${what} not sent yet`;
230
+ const writable = [...scopes.values()].some(
231
+ (entry) => entry.writer && !entry.writer.disabled && Object.keys(entry.edits).length
232
+ );
233
+ const busy = applying || [...scopes.values()].some((entry) => entry.writer?.busy);
234
+ return html`<div class="page-apply" role="group" aria-label=${said}>
235
+ <span class="dot" aria-hidden="true"></span>
236
+ <span class="state">${said}</span>
237
+ <button class="btn small ghost" disabled=${applying} onClick=${discardAll}>Discard</button>
238
+ <button class="btn small primary" disabled=${busy || !writable} onClick=${applyAll}>
239
+ Apply ${what}
240
+ </button>
241
+ </div>`;
242
+ }
243
+
244
+ /* --- the plan, before anything goes -------------------------------------- */
245
+
246
+ function planLine(change) {
247
+ const label = change.label && change.label !== change.name ? change.label : null;
248
+ return html`<li key=${change.name}>
249
+ <span>${label || html`<span class="mono">${change.name}</span>`}</span>
250
+ <span class="muted">${change.oldText} →</span>
251
+ <b>${change.newText}</b>
252
+ </li>`;
253
+ }
254
+
255
+ /* A plan, asked about: what each value is and what it will be. The same
256
+ * dialog whatever made the plan -- a card's tick, the header's Apply, or a
257
+ * program's own button (a chemistry preset) -- so a write looks the same
258
+ * however it was asked for. */
259
+ export function PlanDialog({ plan, title, onCancel, onConfirm, note, sections }) {
260
+ const groups = sections || [{ changes: plan }];
261
+ const count = groups.reduce((n, group) => n + (group.changes?.length ?? group.count ?? 0), 0);
262
+ return html`<${Confirm}
263
+ title=${count ? title || `Apply ${count} change${count === 1 ? '' : 's'}?` : 'Nothing to change'}
264
+ danger=${false}
265
+ confirmLabel=${count ? 'Apply' : 'Close'}
266
+ onCancel=${onCancel}
267
+ onConfirm=${count ? onConfirm : onCancel}
268
+ body=${count
269
+ ? html`<div>
270
+ <p class="muted">Values the device already holds are left out.</p>
271
+ ${note || null}
272
+ ${groups.map((group) =>
273
+ group.changes
274
+ ? group.changes.length
275
+ ? html`<div key=${group.title || 'plan'}>
276
+ ${group.title ? html`<h4 class="plan-head">${group.title}</h4>` : null}
277
+ <ul class="list">
278
+ ${group.changes.map(planLine)}
279
+ </ul>
280
+ </div>`
281
+ : null
282
+ : html`<div key=${group.title}>
283
+ <h4 class="plan-head">${group.title}</h4>
284
+ <p class="muted">
285
+ ${group.count} change${group.count === 1 ? '' : 's'}, sent as they are.
286
+ </p>
287
+ </div>`
288
+ )}
289
+ </div>`
290
+ : html`<p class="muted">The device already holds every value you set.</p>`}
291
+ />`;
292
+ }
293
+
294
+ /* Where the question is asked. Rendered once, at the root of the page: the
295
+ * header that starts most of these draws its own children inside a blurred
296
+ * layer, which a dialog cannot escape from. */
297
+ export function DraftDialog() {
298
+ useDrafts();
299
+ if (!asking) return null;
300
+ const { jobs, answer } = asking;
301
+ const titled = jobs.length > 1;
302
+ const sections = jobs.map((job) => ({
303
+ title: titled ? job.writer.title || job.scope : null,
304
+ changes: job.changes,
305
+ count: job.changes ? undefined : Object.keys(job.edits).length,
306
+ }));
307
+ return html`<${PlanDialog}
308
+ sections=${sections}
309
+ onCancel=${() => answer(false)}
310
+ onConfirm=${() => answer(true)}
311
+ />`;
312
+ }
@@ -0,0 +1,272 @@
1
+ /* Being told the page was raised, when you are not looking at the page.
2
+ *
3
+ * Raising a tab from inside it is mostly wishful: `window.focus()` is a
4
+ * silent no-op in Firefox outside a user gesture, and a tab whose title
5
+ * changed is not highlighted, coloured or animated by anything -- the flash
6
+ * is only readable when the strip is short enough to show more than
7
+ * a truncated program name. A notification is the one signal that leaves
8
+ * the browser
9
+ * altogether, and a click on it *is* a user gesture, so its handler is
10
+ * allowed the `focus()` the event itself is not.
11
+ *
12
+ * It has to be asked for, from a click of your own. Firefox takes
13
+ * `Notification.requestPermission()` only from a user gesture, and a page
14
+ * that asks the moment it loads is the reason for that rule; a refusal is
15
+ * also remembered by the browser for good. So nothing here happens until
16
+ * the bell in the header is pressed, and the answer is kept per browser,
17
+ * next to the theme.
18
+ */
19
+
20
+ import { html, useState } from '/core/vendor/preact-htm.module.js';
21
+
22
+ /* What the program calls itself, in the four sentences this module writes
23
+ * and in the two keys it keeps: the wish, in this browser's storage, and
24
+ * the tag that makes a second run replace the first one's note rather than
25
+ * stack under it. Set once, from the program's own module, before the
26
+ * bell is rendered. */
27
+ let program = 'this program';
28
+ let wantKey = 'devicectl-notify';
29
+ let noteTag = 'devicectl-focus';
30
+
31
+ export function configure({ name }) {
32
+ program = name;
33
+ wantKey = `${name}-notify`;
34
+ noteTag = `${name}-focus`;
35
+ }
36
+
37
+ /* Long enough to find on another screen, short enough not to pile up. */
38
+ const NOTE_MS = 20000;
39
+
40
+ /* Notifications are for secure contexts: this page opened on the machine
41
+ * serving it, or served over https. Shared over plain http to someone
42
+ * else's machine (a `--listen` of somebody else's), Firefox and Chrome take
43
+ * the question away rather than answer it, and the bell has nothing to
44
+ * offer. */
45
+ function supported() {
46
+ return (
47
+ typeof Notification !== 'undefined' &&
48
+ typeof Notification.requestPermission === 'function' &&
49
+ window.isSecureContext !== false
50
+ );
51
+ }
52
+
53
+ /* Which of the two reasons it is, in a sentence.
54
+ *
55
+ * Worth telling apart, because one of them is somebody's own doing and can
56
+ * be undone. A program whose device is on the far end of a serial cable
57
+ * gets run on the machine the cable is in and read from a laptop across the
58
+ * room, and `http://192.168.1.x:8080` is not a secure context -- so the
59
+ * same page that had a bell on the machine serving it has none over the
60
+ * network, which is where it is actually used. That is worth a sentence
61
+ * rather than a control that quietly is not there.
62
+ */
63
+ function whyNot() {
64
+ return window.isSecureContext === false
65
+ ? `This page is not a secure context, so the browser will not offer notifications. ` +
66
+ `Open ${program} on the machine serving it, or put it behind https.`
67
+ : 'This browser does not offer notifications.';
68
+ }
69
+
70
+ function permission() {
71
+ return supported() ? Notification.permission : 'unsupported';
72
+ }
73
+
74
+ function wanted() {
75
+ return localStorage.getItem(wantKey) === '1';
76
+ }
77
+
78
+ /* Show one, and let a click on it do the thing the page may not do itself. */
79
+ function show(title, body) {
80
+ try {
81
+ const note = new Notification(title, { body, tag: noteTag, renotify: true });
82
+ note.onclick = () => {
83
+ /* A click is a user gesture, so this focus() is honoured where the
84
+ * one the focus event tries is quietly dropped. */
85
+ window.focus();
86
+ note.close();
87
+ };
88
+ setTimeout(() => note.close(), NOTE_MS);
89
+ return true;
90
+ } catch {
91
+ /* Android's Chrome sends notifications only through a service worker,
92
+ * which this page has none of. Nothing to do but leave it to the
93
+ * toast, which the tab shows either way. */
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /* The bell's state and the two things it can do, plus the call the event
99
+ * stream makes when the server says a second run of the program wanted this tab.
100
+ *
101
+ * `state` is one of `unsupported` (no notifications in this browser, or not
102
+ * offered on this page), `off`, `on`, or `blocked` -- refused once, which
103
+ * only the browser's own site permissions can undo.
104
+ */
105
+ export function useTabAlerts() {
106
+ const [state, setState] = useState(() => {
107
+ const answer = permission();
108
+ if (answer === 'unsupported') return 'unsupported';
109
+ if (answer === 'denied') return 'blocked';
110
+ return answer === 'granted' && wanted() ? 'on' : 'off';
111
+ });
112
+
113
+ /* Call this from a click and from nothing else; the browser will not take
114
+ * the question any other way. Returns what it answered. */
115
+ const enable = async () => {
116
+ let answer;
117
+ try {
118
+ answer = permission() === 'granted' ? 'granted' : await Notification.requestPermission();
119
+ } catch {
120
+ answer = 'unsupported'; /* offered, then refused to be asked */
121
+ }
122
+ if (answer === 'unsupported') {
123
+ setState('unsupported');
124
+ return answer;
125
+ }
126
+ if (answer !== 'granted') {
127
+ setState(answer === 'denied' ? 'blocked' : 'off');
128
+ return answer;
129
+ }
130
+ localStorage.setItem(wantKey, '1');
131
+ setState('on');
132
+ /* One right away: it proves the whole path works, at the moment the
133
+ * question is still in mind, rather than a week later. */
134
+ return show(`${program} ui`, 'Notifications are on. This is what one looks like.')
135
+ ? 'granted'
136
+ : 'unsupported';
137
+ };
138
+
139
+ const disable = () => {
140
+ localStorage.removeItem(wantKey);
141
+ setState('off');
142
+ };
143
+
144
+ /* Read the wish and the permission afresh rather than closing over
145
+ * `state`: this is called from the event stream's handler, wired up on
146
+ * the first render and remembering nothing since. */
147
+ const notify = () => {
148
+ if (!wanted() || permission() !== 'granted') return false;
149
+ /* You are looking straight at the tab: the toast has already said it. */
150
+ if (document.hasFocus()) return false;
151
+ return show(
152
+ `${program} ui was started again`,
153
+ 'This is the tab it meant -- click here to come back to it.'
154
+ );
155
+ };
156
+
157
+ return { state, enable, disable, notify };
158
+ }
159
+
160
+ const BELL_TITLES = {
161
+ off: 'notify me when this program raises this tab',
162
+ on: 'notifications are on -- click to turn them off',
163
+ blocked: 'this browser has blocked notifications for this page',
164
+ unsupported: 'notifications are not available on this page -- click to find out why',
165
+ };
166
+
167
+ /* The bell, drawn rather than typed.
168
+ *
169
+ * It was the characters U+1F514 and U+1F515, which every platform draws in
170
+ * its own colour at its own size: a fat orange bell next to a 15px line
171
+ * drawing of a theme and a 15px line drawing of a pulse. Same box, same
172
+ * stroke, same cap as those two, so the header's icons are one set -- and
173
+ * the same shape in both programs, which a font cannot promise.
174
+ *
175
+ * On is the bell; off is the bell with the stroke through it that every
176
+ * other muted thing wears. The state is in the shape, not only in the
177
+ * colour, so it survives a screen with no colour to read.
178
+ */
179
+ const BELL_SVG = {
180
+ viewBox: '0 0 16 16',
181
+ width: 15,
182
+ height: 15,
183
+ fill: 'none',
184
+ stroke: 'currentColor',
185
+ 'stroke-width': 1.5,
186
+ 'stroke-linecap': 'round',
187
+ 'stroke-linejoin': 'round',
188
+ 'aria-hidden': 'true',
189
+ focusable: 'false',
190
+ };
191
+
192
+ /* The body, in one stroke: the crown, the shoulder falling away to a skirt
193
+ * that flares, and the flat lip across the bottom.
194
+ *
195
+ * It had been an arch on a rule -- two verticals, a half-round on top and a
196
+ * line under it -- which is the silhouette of a doorway, a tombstone or a
197
+ * tunnel, and at fifteen pixels beside a sun and a pulse it read as none of
198
+ * them in particular. A bell is recognised by its skirt: the sides do not
199
+ * come down straight, they swing out at the bottom. That flare is the
200
+ * whole of the difference, and it costs two curves. */
201
+ const BELL_BODY =
202
+ 'M8 1.6a4.4 4.4 0 0 0-4.4 4.4c0 2.6-.5 4-1.2 4.9a.6.6 0 0 0 .5 1h10.2a.6.6 0 0 0 .5-1' +
203
+ 'c-.7-.9-1.2-2.3-1.2-4.9A4.4 4.4 0 0 0 8 1.6z';
204
+
205
+ /* The clapper, swinging under the lip. */
206
+ const BELL_CLAPPER = 'M6.4 13.3a1.7 1.7 0 0 0 3.2 0';
207
+
208
+ const BELL_ICON = {
209
+ on: html`<svg ...${BELL_SVG}>
210
+ <path d=${BELL_BODY} />
211
+ <path d=${BELL_CLAPPER} />
212
+ </svg>`,
213
+ off: html`<svg ...${BELL_SVG}>
214
+ <path d=${BELL_BODY} />
215
+ <path d=${BELL_CLAPPER} />
216
+ <path d="M2.6 13.6 13.4 2.4" />
217
+ </svg>`,
218
+ };
219
+
220
+ /* The one control, in the header beside the theme: on, off, blocked and not
221
+ * ours to unblock, or not on offer here at all.
222
+ *
223
+ * Always drawn, including that last case. It used to return nothing at
224
+ * all when the browser would not take the question, which is how a program
225
+ * read over a plain-http `--listen` came to have no bell in its header
226
+ * while the one next to it, opened on the machine serving it, had one --
227
+ * the same header, the same module, a control present in one and absent in
228
+ * the other, with nothing anywhere saying why. An absent control cannot be
229
+ * asked what became of it. This one can: it is there, struck through like
230
+ * every other muted thing, and clicking it says what would have to change.
231
+ */
232
+ export function Bell({ alerts, toast }) {
233
+ const state = alerts.state;
234
+ const click = async () => {
235
+ if (state === 'unsupported') {
236
+ toast.info(whyNot());
237
+ return;
238
+ }
239
+ if (state === 'on') {
240
+ alerts.disable();
241
+ toast.info('Notifications off.');
242
+ return;
243
+ }
244
+ if (state === 'blocked') {
245
+ toast.error(
246
+ 'This browser has blocked notifications for this page. Allow them in its site permissions to turn them on.'
247
+ );
248
+ return;
249
+ }
250
+ const answer = await alerts.enable();
251
+ if (answer === 'granted') {
252
+ toast.ok(`Notifications on: ${program} will say when this is the tab it meant.`);
253
+ } else if (answer === 'denied') {
254
+ toast.error(
255
+ 'Notifications blocked. Only this browser can undo that, in its site permissions.'
256
+ );
257
+ } else if (answer === 'unsupported') {
258
+ toast.error('This browser offered notifications and then would not send one.');
259
+ } else {
260
+ toast.info('The browser was not answered, so nothing changed.');
261
+ }
262
+ };
263
+ return html`<button
264
+ class="btn small ghost icon"
265
+ title=${BELL_TITLES[state]}
266
+ aria-label=${BELL_TITLES[state]}
267
+ aria-pressed=${state === 'on'}
268
+ onClick=${click}
269
+ >
270
+ ${BELL_ICON[state === 'on' ? 'on' : 'off']}
271
+ </button>`;
272
+ }