From c669a89faeca8451d401a1232d37043e51ae6c2b Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:20:42 -0400 Subject: [PATCH] Docs: disk health states, clock timezone error, widget copies synced --- public/api/widget-data/gh-diskhealth-md | 4 +- public/js/admin-app-form.js | 17 +-- public/js/admin-auth.js | 13 +- public/js/admin-color-control.js | 16 +-- public/js/admin-drag.js | 6 +- public/js/admin-list.js | 14 +- public/js/admin-logic.js | 4 + public/js/admin-save-logic.js | 29 +++- public/js/admin-settings.js | 92 ++++++++---- public/js/admin-shared.js | 22 ++- public/js/admin-state.js | 1 - public/js/admin-widget-form.js | 16 +-- public/js/admin.js | 108 ++++++++------ public/js/background.js | 2 +- public/js/config-recovery-logic.js | 133 ++++++++++++++++++ public/js/config-recovery.js | 85 +++++++++++ public/js/dashboard.js | 72 ++++++---- public/js/host-names.js | 99 +++++++++++++ public/js/import-foreign.js | 2 +- public/js/jitter.js | 19 +++ public/js/listbox.js | 4 +- public/js/modal.js | 21 ++- public/js/password-strength.js | 8 +- public/js/spotlight.js | 2 +- public/js/ui.js | 4 +- public/js/utils.js | 15 +- public/js/widget-config-form.js | 18 +-- public/js/widget-toolbox.js | 23 +-- public/widgets/backup/backup.html | 34 +++-- public/widgets/backup/data.js | 25 +++- public/widgets/books/data.js | 51 ++++--- public/widgets/books/index.html | 2 +- public/widgets/clock/analog.html | 22 ++- public/widgets/clock/digital.html | 17 +++ public/widgets/clock/i18n/de.json | 3 +- public/widgets/clock/i18n/en.json | 3 +- public/widgets/clock/i18n/es.json | 3 +- public/widgets/clock/i18n/fa.json | 3 +- public/widgets/clock/i18n/fr.json | 3 +- public/widgets/clock/i18n/zh-Hans.json | 3 +- .../widgets/connections/connections-map.html | 12 +- .../widgets/connections/connections-vpn.html | 2 +- public/widgets/dashboard-switch/index.html | 2 +- public/widgets/disk-health/data.js | 11 +- public/widgets/disk-health/i18n/de.json | 2 - public/widgets/disk-health/i18n/en.json | 2 - public/widgets/disk-health/i18n/es.json | 2 - public/widgets/disk-health/i18n/fa.json | 2 - public/widgets/disk-health/i18n/fr.json | 2 - public/widgets/disk-health/i18n/zh-Hans.json | 2 - public/widgets/disk-health/index.html | 16 +-- public/widgets/dns/data.js | 22 +-- public/widgets/dns/index.html | 2 +- public/widgets/github/contributions.html | 2 +- public/widgets/github/data.js | 10 +- public/widgets/github/pullrequests.html | 2 +- public/widgets/mywidget/data.js | 9 +- public/widgets/mywidget/index.html | 3 + public/widgets/nowplaying/index.html | 2 +- public/widgets/system-summary/data.js | 23 ++- public/widgets/system-summary/index.html | 40 +++--- public/widgets/weather/data.js | 4 +- public/widgets/weather/index.html | 4 +- src/content/docs/docs/widgets/clock.mdx | 2 +- src/content/docs/docs/widgets/disk-health.mdx | 4 +- 65 files changed, 888 insertions(+), 314 deletions(-) create mode 100644 public/js/config-recovery-logic.js create mode 100644 public/js/config-recovery.js create mode 100644 public/js/host-names.js diff --git a/public/api/widget-data/gh-diskhealth-md b/public/api/widget-data/gh-diskhealth-md index 8775b46..b2ee643 100644 --- a/public/api/widget-data/gh-diskhealth-md +++ b/public/api/widget-data/gh-diskhealth-md @@ -2,11 +2,11 @@ "bays": [ { "device_id": "1b8f4a20-6c35-4d17-91ea-70c2f5b8d413", "device_status": 0, "hasSmart": true, "model_name": "ST8000VN004-2M2101", "device_name": "sda", "temp": 38, "capacity": 8001563222016 }, { "device_id": "2c9e5b31-7d46-4e28-a2fb-81d306c9e524", "device_status": 0, "hasSmart": true, "model_name": "ST8000VN004-2M2101", "device_name": "sdb", "temp": 39, "capacity": 8001563222016 }, - { "device_id": "3da06c42-8e57-4f39-b30c-92e417daf635", "device_status": 1, "hasSmart": true, "model_name": "TOSHIBA MG08ACA16TE", "device_name": "sdc", "temp": 47, "capacity": 16000900661248 }, + { "device_id": "3da06c42-8e57-4f39-b30c-92e417daf635", "device_status": 0, "hasSmart": true, "model_name": "TOSHIBA MG08ACA16TE", "device_name": "sdc", "temp": 47, "capacity": 16000900661248 }, { "device_id": "4eb17d53-9f68-4a40-c41d-a3f528eb1746", "device_status": 0, "hasSmart": true, "model_name": "TOSHIBA MG08ACA16TE", "device_name": "sdd", "temp": 41, "capacity": 16000900661248 }, { "device_id": "5fc28e64-a079-4b51-d52e-b40639fc0857", "device_status": 2, "hasSmart": true, "model_name": "WDC WD80EAZZ-00BKLB0", "device_name": "sde", "temp": 55, "capacity": 8001563222016 }, { "device_id": "60d39f75-b18a-4c62-e63f-c5174a0d1968", "device_status": 0, "hasSmart": true, "model_name": "WDC WD80EAZZ-00BKLB0", "device_name": "sdf", "temp": 40, "capacity": 8001563222016 }, - { "device_id": "71e4a086-c29b-4d73-f740-d6285b1e2a79", "device_status": 3, "hasSmart": true, "model_name": "WDC WD40EFRX-68N32N0", "device_name": "sdg", "temp": 43, "capacity": 4000787030016 }, + { "device_id": "71e4a086-c29b-4d73-f740-d6285b1e2a79", "device_status": 0, "hasSmart": true, "model_name": "WDC WD40EFRX-68N32N0", "device_name": "sdg", "temp": 43, "capacity": 4000787030016 }, { "device_id": "82f5b197-d3ac-4e84-a851-e7396c2f3b8a", "device_status": 0, "hasSmart": true, "model_name": "CT1000MX500SSD1", "device_name": "sdh", "temp": 33, "capacity": 1000204886016 }, { "device_id": "93a6c208-e4bd-4f95-b962-f84a7d304c9b", "device_status": 0, "hasSmart": false, "model_name": "Samsung SSD 870 EVO 500GB", "device_name": "sdi", "temp": null, "capacity": 500107862016 }, null diff --git a/public/js/admin-app-form.js b/public/js/admin-app-form.js index 79ef848..3c05f5e 100644 --- a/public/js/admin-app-form.js +++ b/public/js/admin-app-form.js @@ -1,7 +1,7 @@ -import { clr, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=b1cfbd45'; +import { clr, el, inp as inpById, q as qSel, qa, qi, tgt } from '/js/utils.js?v=c5766a9d'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, resolveIcon, iconChain, cdnIconRef, splitIconRef } from '/js/icons.js?v=9c8c550c'; -import { state } from '/js/admin-state.js?v=831e219e'; +import { state } from '/js/admin-state.js?v=af772a1b'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { isDockBlocked, @@ -10,10 +10,11 @@ import { failureIsMissingApiPath, nextActiveIndex, sameIconName, -} from '/js/admin-logic.js?v=cbb7417d'; +} from '/js/admin-logic.js?v=fc7f0836'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { toast, + responseError, apiGet, apiPost, PE_SVG, @@ -21,10 +22,10 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=3870d0d0'; -import { createListbox } from '/js/listbox.js?v=6e7b7060'; +} from '/js/admin-shared.js?v=a81b9cbe'; +import { createListbox } from '/js/listbox.js?v=9a8ae607'; import { MAX_LABELS } from '/js/badge-logic.js?v=9e6d9d4b'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=844bdeb2'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=0d4d1038'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=f82486b2'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; @@ -707,8 +708,8 @@ function wireIcon() { const form = new FormData(); form.append('icon', file, file.name); const r = await fetch('/api/icons/upload', { method: 'POST', body: form }); + if (!r.ok) throw new Error(await responseError(r)); const d = await r.json(); - if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); await loadLocalIcons(); state.siurl = d.filename; const ipIn = inpById('ip-in'); @@ -985,7 +986,7 @@ async function fetchBadge() { params, headers, skipTls, - itemId: state.eid !== null ? state.items[state.eid]?.id : undefined, + itemId: state.eid ?? undefined, }); state.fnums = r.numbers || []; if (st) { diff --git a/public/js/admin-auth.js b/public/js/admin-auth.js index 8b17940..ab3c802 100644 --- a/public/js/admin-auth.js +++ b/public/js/admin-auth.js @@ -1,7 +1,8 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=3870d0d0'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=a81b9cbe'; import { t } from '/js/i18n.js?v=1f1ea9c1'; -import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; -import { el, inp as inpById, qa } from '/js/utils.js?v=b1cfbd45'; +import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; +import { el, inp as inpById, qa } from '/js/utils.js?v=c5766a9d'; +import { blockingScreenFor } from '/js/config-recovery.js?v=706fc9a7'; export async function checkAuth(onLogin) { try { @@ -14,7 +15,7 @@ export async function checkAuth(onLogin) { showLoginScreen(onLogin); return false; } - return true; + return !blockingScreenFor(e); } } @@ -70,15 +71,13 @@ export function wirePasswordStrength(inputId, barsId, hintId) { const bars = qa('.pwbar', el(barsId)); const hint = el(hintId); if (!inp || !bars?.length) return; - const dim = 'rgba(255,255,255,.1)'; inp.addEventListener('input', () => { const { score, labelKey, color } = pwStrength(inp.value); bars.forEach((b, i) => { - b.style.background = inp.value && i < score ? color : dim; + b.style.background = inp.value && i < score ? color : ''; }); if (hint) { hint.textContent = inp.value && labelKey ? t(labelKey) : ''; - hint.style.color = color; } }); } diff --git a/public/js/admin-color-control.js b/public/js/admin-color-control.js index 6234bfb..e06c800 100644 --- a/public/js/admin-color-control.js +++ b/public/js/admin-color-control.js @@ -1,7 +1,7 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=3870d0d0'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=a81b9cbe'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=b1cfbd45'; +import { qa, q } from '/js/utils.js?v=c5766a9d'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; @@ -145,7 +145,7 @@ const _swatchFill = v => tileColor(v, pageTheme()); swatchColors?: string[], label?: string }} [opts] */ export function renderColorControl( container, - { value = '#0289ff', idPrefix, onChange, variant, swatchColors = CC_SWATCHES, label = 'Color' } = {}, + { value = '#0289ff', idPrefix, onChange, variant, swatchColors = CC_SWATCHES, label = t('common.color') } = {}, ) { const top = variant === 'tile' ? TILE_KEYWORDS : variant === 'badge' ? ROLE_NAMES : []; const hues = variant ? HUE_NAMES : []; @@ -169,10 +169,10 @@ export function renderColorControl(
${label}
${swatches}
${hueRow} - ${slider('Hue', 'hsb-range hsb-hue', `${idPrefix}-h`, 360, init.h, _ccIco.hueLo, _ccIco.hueHi)} - ${slider('Saturation', 'hsb-range', `${idPrefix}-s`, 100, init.s, _ccIco.satLo, _ccIco.satHi)} - ${slider('Brightness', 'hsb-range', `${idPrefix}-v`, 100, init.v, _ccIco.brLo, _ccIco.brHi)} -
${t('appearance.colorCode')}#rrggbb or any CSS color
+ ${slider(t('appearance.hue'), 'hsb-range hsb-hue', `${idPrefix}-h`, 360, init.h, _ccIco.hueLo, _ccIco.hueHi)} + ${slider(t('appearance.saturation'), 'hsb-range', `${idPrefix}-s`, 100, init.s, _ccIco.satLo, _ccIco.satHi)} + ${slider(t('appearance.brightness'), 'hsb-range', `${idPrefix}-v`, 100, init.v, _ccIco.brLo, _ccIco.brHi)} +
${t('appearance.colorCode')}${t('appearance.colorCodePh')}
`, ); /* The markup carries no style attribute, so the page keeps style-src without @@ -280,7 +280,7 @@ export function renderColorControl( ); initInlineEdit(`${idPrefix}-code-row`, `${idPrefix}-hex`, { root: container, - placeholder: '#rrggbb or any CSS color', + placeholder: t('appearance.colorCodePh'), onCommit(val) { if (mode !== 'color' && val === kwName(mode)) return; const { value: parsed, ok } = normalizeColorInput(val); diff --git a/public/js/admin-drag.js b/public/js/admin-drag.js index 37a1df4..f160c56 100644 --- a/public/js/admin-drag.js +++ b/public/js/admin-drag.js @@ -10,9 +10,9 @@ it is near, and which classes say so. */ import { applyDrop, canJoinFolder, folderRowZone } from '/js/admin-drag-logic.js?v=6b767e76'; -import { state } from '/js/admin-state.js?v=831e219e'; -import { snapshotItems } from '/js/admin-save-logic.js?v=60a82419'; -import { qa } from '/js/utils.js?v=b1cfbd45'; +import { state } from '/js/admin-state.js?v=af772a1b'; +import { snapshotItems } from '/js/admin-save-logic.js?v=8389782f'; +import { qa } from '/js/utils.js?v=c5766a9d'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/public/js/admin-list.js b/public/js/admin-list.js index ff3fb1c..6d15b30 100644 --- a/public/js/admin-list.js +++ b/public/js/admin-list.js @@ -11,15 +11,15 @@ The filter and the collapsed folders are in admin-state.js, because the page reads and writes them too. */ -import { collapsedFolders, filter, state } from '/js/admin-state.js?v=831e219e'; -import { snapshotItems } from '/js/admin-save-logic.js?v=60a82419'; -import { reorderItems } from '/js/admin-logic.js?v=cbb7417d'; -import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=7fe4a0bf'; -import { paintIcon } from '/js/admin-shared.js?v=3870d0d0'; -import { clr, el, qa, setUserText } from '/js/utils.js?v=b1cfbd45'; +import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; +import { snapshotItems } from '/js/admin-save-logic.js?v=8389782f'; +import { reorderItems } from '/js/admin-logic.js?v=fc7f0836'; +import { initDrag, wireRowDrag } from '/js/admin-drag.js?v=18af71d8'; +import { paintIcon } from '/js/admin-shared.js?v=a81b9cbe'; +import { clr, el, qa, setUserText } from '/js/utils.js?v=c5766a9d'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=1f1ea9c1'; -import { sizeLabel } from '/js/admin-widget-form.js?v=bcfb87e2'; +import { sizeLabel } from '/js/admin-widget-form.js?v=ef53bce9'; import { widgetGlyph } from '/js/widget-glyphs.js?v=648cc374'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; diff --git a/public/js/admin-logic.js b/public/js/admin-logic.js index f183948..1a1e529 100644 --- a/public/js/admin-logic.js +++ b/public/js/admin-logic.js @@ -182,6 +182,10 @@ export function clearsStoredPassword({ enabled, wasEnabled, passwordSet }) { return !enabled && !!wasEnabled && !!passwordSet; } +export function needsCurrentPassword({ enabled, wasEnabled, passwordSet, newPassword }) { + return !!wasEnabled && !!passwordSet && (!enabled || !!(newPassword || '').length); +} + /* 'unavailable' must not fall through to the custom editor. The server withholds that widget's config, so empty fields read as lost settings. */ export function widgetConfigMode(type, reg) { diff --git a/public/js/admin-save-logic.js b/public/js/admin-save-logic.js index 6ac88ae..c435068 100644 --- a/public/js/admin-save-logic.js +++ b/public/js/admin-save-logic.js @@ -50,21 +50,44 @@ export function snapshotItems(items) { /** Run `write` and undo the local change when it did not reach the server. Without this the list shows a dashboard the server does not have. `write` reports failure by resolving false; a throw is re-raised after the restore. + `superseded` reports a later change still waiting to save. Its write carries + this change too, so restoring here would drop the later change. @template T @param {{ write: () => Promise, snapshot: T, - restore: (snapshot: T) => void }} opts + restore: (snapshot: T) => void, superseded?: () => boolean }} opts @returns {Promise} */ -export async function saveWithRevert({ write, snapshot, restore }) { +export async function saveWithRevert({ write, snapshot, restore, superseded = () => false }) { let ok = false; try { ok = (await write()) !== false; } finally { - if (!ok) restore(snapshot); + if (!ok && !superseded()) restore(snapshot); } return ok; } +/** Run writes one at a time, in the order asked. A write asked for while + another runs waits for it instead of being dropped. + + @returns {{ run: (write: () => Promise) => Promise, pending: () => number }} */ +export function serialWrites() { + /** @type {Promise} */ + let tail = Promise.resolve(); + let pending = 0; + return { + run(write) { + pending++; + const p = tail.then(write).finally(() => { + pending--; + }); + tail = p.catch(() => {}); + return p; + }, + pending: () => pending, + }; +} + /** Put `item` where the item with `id` currently is, or append it. Match by id, never by position: a position goes stale as soon as items move, and writing past the end grows the array with holes that JSON turns into nulls. diff --git a/public/js/admin-settings.js b/public/js/admin-settings.js index eba58e8..081ee4c 100644 --- a/public/js/admin-settings.js +++ b/public/js/admin-settings.js @@ -1,17 +1,19 @@ -import { toast, apiGet, apiPost, reveal, swapContent } from '/js/admin-shared.js?v=3870d0d0'; -import { pwStrength } from '/js/password-strength.js?v=42f45ac7'; +import { toast, apiGet, apiPost, reveal, swapContent } from '/js/admin-shared.js?v=a81b9cbe'; +import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { shouldWritePassword, settingsSaveBlocker, clearsStoredPassword, + needsCurrentPassword, createDirtyTracker, BLOCK, -} from '/js/admin-logic.js?v=cbb7417d'; -import { confirmText } from '/js/modal.js?v=11fa1eff'; -import { el, inp, setUserText } from '/js/utils.js?v=b1cfbd45'; -import { renderColorControl } from '/js/admin-color-control.js?v=844bdeb2'; -import { BACKDROP } from '/js/background.js?v=cd1cc453'; +} from '/js/admin-logic.js?v=fc7f0836'; +import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; +import { el, inp, setUserText } from '/js/utils.js?v=c5766a9d'; +import { renderColorControl } from '/js/admin-color-control.js?v=0d4d1038'; +import { BACKDROP } from '/js/background.js?v=5f478ebf'; +import { firstBadHost, hostnameOf, isLocalAddress, parseHostList } from '/js/host-names.js?v=842f96ca'; /* Mirrors the server's rule: auth cannot be switched on with no password. */ let _passwordSet = false; @@ -21,6 +23,10 @@ let _authEnabled = false; let _srvTrack = null; /** @type {{ dirty: () => boolean, reset: (force?: boolean) => void } | null} */ let _bgTrack = null; +let _savedWallpaperUrl = ''; + +/** The wallpaper link the server holds, for undoing a link that failed. */ +export const savedWallpaperUrl = () => _savedWallpaperUrl; const _val = (...ids) => { for (const id of ids) { @@ -42,6 +48,7 @@ const readServerForm = () => _val('srv-docker-en'), _val('srv-socket'), _val('srv-hide-healthy'), + _val('srv-hosts'), _val('log-level'), _val('lang-sel'), _val('sec-en'), @@ -139,6 +146,7 @@ export function loadSettings(c) { aw.addEventListener('change', saveKeepAwake); } const bg = s.background || { type: 'unsplash', brightness: 0.62 }; + _savedWallpaperUrl = bg.url || ''; const typeEl = inp('bg-type'); if (typeEl) { typeEl.value = bg.type || 'unsplash'; @@ -204,6 +212,7 @@ export function loadSettings(c) { }; _sv('ie-ip-v', s.server?.hostIp, '192.168.1.100'); _sv('ie-socket-v', s.server?.socketProxyUrl, 'http://socket-proxy:2375'); + _sv('ie-hosts-v', (s.server?.allowedHosts || []).join(', '), t('general.allowedHostsNone')); _sv('ie-pw-v', '', t('common.notSet')); /* set below after auth check */ const _si = (id, v) => { const node = inp(id); @@ -211,6 +220,7 @@ export function loadSettings(c) { }; _si('srv-ip', s.server?.hostIp || ''); _si('srv-socket', s.server?.socketProxyUrl || ''); + _si('srv-hosts', (s.server?.allowedHosts || []).join(', ')); _sv('ie-bgcol-v', s.background?.collection, 'Collection ID'); _si('bg-col-inp', s.background?.collection || ''); _si('bg-url-inp', s.background?.url || ''); @@ -371,6 +381,7 @@ async function saveWallpaper() { c.settings = c.settings || {}; c.settings.background = bg; await apiPost('/api/config', c); + _savedWallpaperUrl = bg.url || ''; /* After the main config. GET /api/config strips the key, so a config write that follows would overwrite it with nothing. */ if (type === 'unsplash') { @@ -383,6 +394,12 @@ async function saveWallpaper() { toast(t('toast.saveFailed', { err: e.message }), 'err'); } } +const PASSWORD_ERROR_KEYS = Object.freeze({ + 'invalid.current-password': 'toast.currentPasswordWrong', + 'invalid.password-changed': 'toast.passwordChangedElsewhere', + 'blocked.rate-limit': 'toast.tooManyAttempts', +}); + async function saveServer() { const pw = inp('sec-pw')?.value || ''; const enabled = inp('sec-en')?.checked || false; @@ -401,6 +418,19 @@ async function saveServer() { return; } + const hostsText = inp('srv-hosts')?.value || ''; + const badHost = firstBadHost(hostsText); + if (badHost) { + toast(t('toast.allowedHostInvalid', { host: badHost }), 'err'); + return; + } + const allowedHosts = parseHostList(hostsText); + const here = hostnameOf(location.host); + if (!enabled && here && !isLocalAddress(here) && !allowedHosts.includes(here)) { + toast(t('toast.allowedHostsKeepCurrent', { host: here }), 'err'); + return; + } + /* Asked with the other refusals, so a wrong address never reaches the config. */ if (inp('srv-docker-en')?.checked) { @@ -431,21 +461,40 @@ async function saveServer() { /* Switching protection off deletes the stored password. Ask before anything is written. */ - if (clearsStoredPassword({ enabled, wasEnabled: _authEnabled, passwordSet: _passwordSet })) { - const ok = await confirmText({ + /** @type {string|undefined} */ + let currentPassword; + const was = { enabled, wasEnabled: _authEnabled, passwordSet: _passwordSet }; + if (needsCurrentPassword({ ...was, newPassword: pw })) { + const clearing = clearsStoredPassword(was); + const answer = await promptModal({ title: t('general.passwordProtection'), - text: t('confirm.clearPassword'), - confirmLabel: t('common.delete'), + text: t(clearing ? 'confirm.clearPassword' : 'confirm.changePassword'), + label: t('general.currentPassword'), + password: true, + destructive: clearing, + confirmLabel: t(clearing ? 'common.delete' : 'common.save'), cancelLabel: t('common.cancel'), - destructive: true, }); - if (!ok) { - await syncAuthFromServer(); + if (answer === null) { + if (clearing) await syncAuthFromServer(); return; } + currentPassword = answer; } try { + /* Keep before the config read. These writes bump the config revision, and + a config save built on an earlier read is refused as stale. */ + if (shouldWritePassword({ enabled, newPassword: pw })) { + await apiPost('/api/auth/set-password', { password: pw, currentPassword }); + const pwEl = inp('sec-pw'); + if (pwEl) { + pwEl.value = ''; + pwEl.placeholder = '●●●●●●●●●● (configured)'; + } + } + await apiPost('/api/auth/toggle', { enabled, currentPassword, allowedHosts }); + const c = await apiGet('/api/config'); c.settings = c.settings || {}; const prevLang = c.settings.language || 'en'; @@ -456,22 +505,13 @@ async function saveServer() { hostIp: inp('srv-ip')?.value?.trim() || '', socketProxyUrl: dockerEnabled ? socketUrl : '', hideHealthyBadge: inp('srv-hide-healthy')?.checked !== false, + allowedHosts, }; c.settings.logLevel = inp('log-level')?.value || 'info'; c.settings.language = inp('lang-sel')?.value || 'en'; const langChanged = c.settings.language !== prevLang; await apiPost('/api/config', c); - - if (shouldWritePassword({ enabled, newPassword: pw })) { - await apiPost('/api/auth/set-password', { password: pw }); - const pwEl = inp('sec-pw'); - if (pwEl) { - pwEl.value = ''; - pwEl.placeholder = '●●●●●●●●●● (configured)'; - } - } - await apiPost('/api/auth/toggle', { enabled }); if (!enabled) { const pwEl = inp('sec-pw'); if (pwEl) { @@ -489,7 +529,9 @@ async function saveServer() { /* Read back from the server, never inferred from what was asked for. */ await syncAuthFromServer(); } catch (e) { - toast(t('toast.saveFailed', { err: e.message }), 'err'); + const key = PASSWORD_ERROR_KEYS[/** @type {any} */ (e).code]; + if (key) toast(t(key), 'err'); + else toast(t('toast.saveFailed', { err: e.message }), 'err'); await syncAuthFromServer(); } } diff --git a/public/js/admin-shared.js b/public/js/admin-shared.js index 39d60f8..eec202d 100644 --- a/public/js/admin-shared.js +++ b/public/js/admin-shared.js @@ -1,9 +1,10 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ -import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=cbb7417d'; -import { el, q } from '/js/utils.js?v=b1cfbd45'; +import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=fc7f0836'; +import { el, q } from '/js/utils.js?v=c5766a9d'; import { t } from '/js/i18n.js?v=1f1ea9c1'; import { iconChain } from '/js/icons.js?v=9c8c550c'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; +import { blockingScreenFor, recoveryShown, showBlockingScreen } from '/js/config-recovery.js?v=706fc9a7'; export const API = ''; @@ -13,6 +14,7 @@ let _toastWired = false; /** @param {string} m @param {'ok'|'err'} [tone] @returns {void} */ export const toast = (m, tone = 'ok') => { const e = el('toast'); + if (!e) return; e.textContent = m; e.className = `show ${tone}`; clearTimeout(tt); @@ -46,6 +48,8 @@ export const toast = (m, tone = 'ok') => { /** @param {number} status @param {any} body @returns {ApiError} */ function tagged(status, body) { + const screen = status === 403 || status === 503 ? blockingScreenFor(body) : null; + if (screen && !recoveryShown()) showBlockingScreen(screen); const e = /** @type {ApiError} */ (new Error((body && body.error) || 'HTTP ' + status)); e.status = status; if (body && typeof body.kind === 'string') e.kind = body.kind; @@ -53,6 +57,20 @@ function tagged(status, body) { if (body && body.detail && typeof body.detail === 'object') e.detail = body.detail; return e; } + +/** A body that is not JSON is the web server answering on its own. + + @param {Response} r @returns {Promise} */ +export async function responseError(r) { + const text = await r.text().catch(() => ''); + try { + const d = JSON.parse(text); + if (d && d.error) return String(d.error); + } catch {} + if (r.status === 413) return t('toast.imageTooLarge'); + return `HTTP ${r.status}`; +} + /* Set by the admin entry point, never imported. The sign-in screen imports this module, so importing it back is a cycle. */ /** @type {(() => Promise) | null} */ diff --git a/public/js/admin-state.js b/public/js/admin-state.js index 8d4d9b7..5175523 100644 --- a/public/js/admin-state.js +++ b/public/js/admin-state.js @@ -4,7 +4,6 @@ export const state = { items: [], /* An id, never a position. A position goes stale as soon as items move. */ eid: null, - saving: false, _settings: {}, _widgetReg: Object.create(null), /* Widgets the server found but refused, as { name, errors }. */ diff --git a/public/js/admin-widget-form.js b/public/js/admin-widget-form.js index 83576c2..22ceeeb 100644 --- a/public/js/admin-widget-form.js +++ b/public/js/admin-widget-form.js @@ -1,11 +1,11 @@ -import { state } from '/js/admin-state.js?v=831e219e'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=3870d0d0'; -import { createListbox } from '/js/listbox.js?v=6e7b7060'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=1dcd31e2'; +import { state } from '/js/admin-state.js?v=af772a1b'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=a81b9cbe'; +import { createListbox } from '/js/listbox.js?v=9a8ae607'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=d95ac911'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=cbb7417d'; +import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=fc7f0836'; import { t } from '/js/i18n.js?v=1f1ea9c1'; -import { q, qi, qa } from '/js/utils.js?v=b1cfbd45'; +import { q, qi, qa } from '/js/utils.js?v=c5766a9d'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); @@ -117,11 +117,9 @@ function _renderWidgetForm(body) { if (_mode === 'registry') { const d = document.createElement('div'); body.appendChild(d); - const _wid = - state.eid !== null && state.items[state.eid] && state.items[state.eid].id ? state.items[state.eid].id : null; const _vf = state._widgetReg[state._wtype].viewField; state._autoForm = renderWidgetConfigForm(d, state._widgetReg[state._wtype].fields || [], state._wAutoCfg, { - widgetId: _wid, + widgetId: state.eid, widgetType: state._wtype, size: state._wsize, /* A view switch can change which sizes are offered, and the tiles are diff --git a/public/js/admin.js b/public/js/admin.js index 1a252b6..d0c5ad1 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,30 +1,39 @@ -import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=e203674f'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=a3a03ef9'; -import { initList, render, syncFilterUI } from '/js/admin-list.js?v=fc772edc'; -import { resolveAdminSection } from '/js/admin-logic.js?v=cbb7417d'; +import { buildAppForm, buildFolderForm, captureActLabels, serializeKvRows } from '/js/admin-app-form.js?v=7949fa07'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=9035d41b'; +import { recoveryShown } from '/js/config-recovery.js?v=706fc9a7'; +import { initList, render, syncFilterUI } from '/js/admin-list.js?v=f868b68c'; +import { resolveAdminSection } from '/js/admin-logic.js?v=fc7f0836'; import { buildAppItem, claimFolderChildren, newItemId, saveWithRevert, + serialWrites, snapshotItems, upsertItem, -} from '/js/admin-save-logic.js?v=60a82419'; -import { loadSettings, settingsDirty, showBgFields, showWallpaperFile } from '/js/admin-settings.js?v=ac7685df'; +} from '/js/admin-save-logic.js?v=8389782f'; +import { + loadSettings, + savedWallpaperUrl, + settingsDirty, + showBgFields, + showWallpaperFile, +} from '/js/admin-settings.js?v=4354171b'; import { apiGet, apiPost, initInlineEdit, paintIcon, reveal, + responseError, setReauthHandler, toast, -} from '/js/admin-shared.js?v=3870d0d0'; -import { collapsedFolders, filter, state } from '/js/admin-state.js?v=831e219e'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=bcfb87e2'; +} from '/js/admin-shared.js?v=a81b9cbe'; +import { collapsedFolders, filter, state } from '/js/admin-state.js?v=af772a1b'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=ef53bce9'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=8b39e9d0'; -import { createListbox } from '/js/listbox.js?v=6e7b7060'; +import { createListbox } from '/js/listbox.js?v=9a8ae607'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=1f1ea9c1'; import { loadLocalIcons } from '/js/icons.js?v=9c8c550c'; @@ -37,9 +46,9 @@ import { NOTE, parseErrorsAsSkipped, SKIP, -} from '/js/import-foreign.js?v=f9c0a120'; +} from '/js/import-foreign.js?v=2aa3bf02'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; -import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=11fa1eff'; +import { confirmModal, confirmText, openModal as openDialog, promptModal } from '/js/modal.js?v=6b0320bd'; import { THEME_KEY, applyTheme, @@ -49,8 +58,8 @@ import { watchSystemTheme, writeMode, } from '/js/theme.js?v=eeafa4b5'; -import { el, inp, q, qa, clr, setUserText, tgt } from '/js/utils.js?v=b1cfbd45'; -import { applyBackground, resolveBackground } from '/js/background.js?v=cd1cc453'; +import { el, inp, q, qa, clr, setUserText, tgt } from '/js/utils.js?v=c5766a9d'; +import { applyBackground, resolveBackground } from '/js/background.js?v=5f478ebf'; import { parseYamlTolerant, YamlLiteError } from '/js/yaml-lite.js?v=6ebb564c'; ensureSprite(); @@ -71,6 +80,7 @@ onLayoutChange(_syncMobile, _mobileAtLoad); async function load() { await loadLocalIcons(); const c = await apiGet('/api/config'); + _serverItems = JSON.stringify(c.items || []); state.items = c.items || []; state._settings = c.settings || {}; await initI18n(c.settings?.language || 'en'); @@ -107,22 +117,31 @@ async function applyBg() { if (bg) applyBackground(document.documentElement, bg); else if (s.type === 'unsplash') toast(t('toast.wallpaperUnavailable'), 'err'); } +/* The list as the server last returned it to this page. A save that finds a + different list on the server would delete what another tab or device added. */ +let _serverItems = ''; +const saves = serialWrites(); + /** Returns whether the write reached the server. */ -async function save() { - if (state.saving) return false; - state.saving = true; +function save() { + return saves.run(writeItems); +} + +async function writeItems() { let ok = false; try { const full = await apiGet('/api/config'); + if (JSON.stringify(full.items || []) !== _serverItems) throw Object.assign(new Error('stale'), { status: 409 }); + const sent = JSON.stringify(state.items); full.items = state.items; - await apiPost('/api/config', full); - _savedItems = JSON.stringify(state.items); + const r = await apiPost('/api/config', full); + _serverItems = JSON.stringify(r.items); + _savedItems = sent; toast(t('toast.saved')); ok = true; } catch (e) { - toast(t('toast.saveFailed', { err: e.message }), 'err'); + toast(e.status === 409 ? t('toast.dashboardChangedElsewhere') : t('toast.saveFailed', { err: e.message }), 'err'); } - state.saving = false; render(); syncDashSave(); return ok; @@ -133,9 +152,12 @@ async function save() { tab while the preview was open. @param {any[]} newItems */ -async function appendAndSave(newItems) { - if (state.saving) throw new Error('A save is already in progress'); - state.saving = true; +function appendAndSave(newItems) { + return saves.run(() => appendItems(newItems)); +} + +/** @param {any[]} newItems */ +async function appendItems(newItems) { try { const full = await apiGet('/api/config'); const current = Array.isArray(full.items) ? full.items : []; @@ -144,12 +166,12 @@ async function appendAndSave(newItems) { const clash = newItems.find(i => taken.has(i.id)); if (clash) throw new Error(`${clash.label}: this id already exists. Reload and import again.`); full.items = [...current, ...newItems]; - await apiPost('/api/config', full); + const r = await apiPost('/api/config', full); + _serverItems = JSON.stringify(r.items); state.items = full.items; _savedItems = JSON.stringify(state.items); syncDashSave(); } finally { - state.saving = false; render(); } } @@ -164,6 +186,7 @@ async function saveOrRevert(before) { state.items = items; render(); }, + superseded: () => saves.pending() > 0, }); } catch { return false; @@ -174,9 +197,7 @@ function showListView() { el('dash-list-view').classList.remove('d-none'); el('dash-edit-view').classList.add('d-none'); } -/* Keep both. The pane scrolls on a wide window, the document on a phone. */ function scrollSettingsTop() { - q('.cp')?.scrollTo?.(0, 0); scrollTo(0, 0); } @@ -471,7 +492,12 @@ function openFolderPicker(appId, targetFolderId = null) { dlg.focus(q('button', list)); } +/* A second press during the write would add a new item a second time. */ +let _editorSaving = false; + async function doSave(orig) { + if (_editorSaving) return; + _editorSaving = true; try { /** @type {Record} */ let item; @@ -593,6 +619,8 @@ async function doSave(orig) { toast(t(replaced ? 'toast.updated' : 'toast.added')); } catch (e) { toast(t('toast.error', { err: e.message }), 'err'); + } finally { + _editorSaving = false; } } @@ -623,6 +651,7 @@ function initNav() { function initAllInlineEdits() { initInlineEdit('ie-ip', 'srv-ip', { placeholder: '192.168.1.100' }); initInlineEdit('ie-socket', 'srv-socket', { placeholder: 'http://socket-proxy:2375' }); + initInlineEdit('ie-hosts', 'srv-hosts', { placeholder: () => t('general.allowedHostsNone') }); initInlineEdit('ie-pw', 'sec-pw', { type: 'password', @@ -789,19 +818,6 @@ function initBgFit() { apply(hidden.value || 'fill'); } -/** A body that is not JSON is the web server answering on its own. - - @param {Response} r @returns {Promise} */ -async function responseError(r) { - const text = await r.text().catch(() => ''); - try { - const d = JSON.parse(text); - if (d && d.error) return String(d.error); - } catch {} - if (r.status === 413) return t('toast.imageTooLarge'); - return `HTTP ${r.status}`; -} - /** @param {string} url an image this server holds @returns {void} */ function setWallpaperUrl(url) { const urlInp = inp('bg-url-inp'); @@ -859,7 +875,7 @@ async function fetchWallpaperLink(url) { toast(t('toast.wallpaperStored')); } catch (e) { /* A link that failed must not replace the wallpaper already saved. */ - setWallpaperUrl(state._settings?.background?.url || ''); + setWallpaperUrl(savedWallpaperUrl()); toast(t('toast.wallpaperFailed', { err: e.message }), 'err'); } } @@ -978,6 +994,7 @@ function syncDashSave() { if (dashSaveEl) dashSaveEl.disabled = JSON.stringify(state.items) === _savedItems; } addEventListener('beforeunload', e => { + if (recoveryShown()) return; if (!settingsDirty() && JSON.stringify(state.items) === _savedItems) return; e.preventDefault(); e.returnValue = ''; @@ -1242,8 +1259,7 @@ initTheme(); setReauthHandler(requireLogin); -checkAuth(load).then(ok => { - if (!ok) return; +const loadOrShowFailure = () => load().catch(e => { toast(t('toast.configLoadFailed', { err: e.message }), 'err'); const al = el('al'); @@ -1254,6 +1270,10 @@ checkAuth(load).then(ok => { html`
${t('home.loadFailed')}

`, ); q('.retry-btn', al)?.addEventListener('click', () => location.reload()); + el('al-grp')?.classList.remove('d-none'); } }); + +checkAuth(loadOrShowFailure).then(ok => { + if (ok) loadOrShowFailure(); }); diff --git a/public/js/background.js b/public/js/background.js index a29f231..cba313f 100644 --- a/public/js/background.js +++ b/public/js/background.js @@ -1,5 +1,5 @@ // @ts-check -import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=b1cfbd45'; +import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=c5766a9d'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; export const BACKDROP = '#0d1117'; diff --git a/public/js/config-recovery-logic.js b/public/js/config-recovery-logic.js new file mode 100644 index 0000000..33d9ef6 --- /dev/null +++ b/public/js/config-recovery-logic.js @@ -0,0 +1,133 @@ +// @ts-check +/* What the settings-file recovery screen says. Keep it free of the DOM and of + imports: the API requires it. */ + +const DOCS = 'https://stackyard.sandobserver.com/docs/troubleshooting/'; +export const HELP_URL = `${DOCS}#stackyard-cannot-read-its-settings-file`; +export const HOST_HELP_URL = `${DOCS}#stackyard-does-not-answer-on-this-address`; + +export const DAMAGE_CODES = Object.freeze({ + corrupt: 'internal.config-corrupt', + unreadable: 'internal.config-unreadable', +}); + +// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what it refuses +const FILE_NAME = /^[^\u0000-\u001f\u007f/\\]{1,255}$/; + +export const HOST_BLOCKED_CODE = 'blocked.host'; + +/** The address an API error body refuses, or null when it refuses none. + @param {unknown} body @returns {{ host: string } | null} */ +export function readHostBlock(body) { + if (!body || typeof body !== 'object') return null; + const b = /** @type {Record} */ (body); + if (b.code !== HOST_BLOCKED_CODE) return null; + const host = b.detail && typeof b.detail.host === 'string' ? b.detail.host : ''; + // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what it refuses + return { host: /^[^\u0000-\u001f\u007f]{1,255}$/.test(host) ? host : '' }; +} + +/** The damage an API error body reports, or null when it reports none. + @param {unknown} body + @returns {{ reason: 'corrupt' | 'unreadable', file: string, backup: string | null } | null} */ +export function readConfigDamage(body) { + if (!body || typeof body !== 'object') return null; + const b = /** @type {Record} */ (body); + const reason = Object.keys(DAMAGE_CODES).find(r => DAMAGE_CODES[r] === b.code); + if (!reason) return null; + const detail = b.detail && typeof b.detail === 'object' ? b.detail : {}; + const file = typeof detail.file === 'string' && FILE_NAME.test(detail.file) ? detail.file : 'apps.json'; + const backup = typeof detail.backup === 'string' && FILE_NAME.test(detail.backup) ? detail.backup : null; + return { reason: /** @type {'corrupt' | 'unreadable'} */ (reason), file, backup }; +} + +/** Translated markup with each name escaped once. The names are filled in after + the markup is sanitized, so a name can never add a tag. + @param {(vars: Record) => string} translate + @param {Record | undefined} vars + @param {(s: string) => string} sanitize @param {(s: string) => string} escapeName + @returns {string} */ +export function fillNames(translate, vars, sanitize, escapeName) { + const given = vars || {}; + const names = Object.keys(given); + const marker = i => `\u0001${i}\u0001`; + let out = sanitize(translate(Object.fromEntries(names.map((n, i) => [n, marker(i)])))); + names.forEach((n, i) => { + out = out.split(marker(i)).join(escapeName(given[n])); + }); + return out; +} + +/** @param {{ reason: string, file: string, backup: string | null }} damage + @returns {{ key: string, vars?: Record }[]} */ +export function recoverySteps({ reason, file, backup }) { + const steps = []; + if (reason === 'unreadable') steps.push({ key: 'configRecovery.stepPermissions', vars: { file } }); + else { + steps.push( + backup + ? { key: 'configRecovery.stepRepairBackup', vars: { file, backup } } + : { key: 'configRecovery.stepRepair', vars: { file } }, + ); + steps.push({ key: 'configRecovery.stepExport', vars: { file } }); + } + steps.push({ key: 'configRecovery.stepStartOver', vars: { file } }); + steps.push({ key: 'configRecovery.stepCheck' }); + return steps; +} + +/** The first browser language Stackyard ships, else English. The saved + language is in the file that cannot be read. + @param {readonly string[]} preferred @param {readonly string[]} supported + @returns {string} */ +export function pickLanguage(preferred, supported) { + for (const tag of preferred || []) { + const want = String(tag); + const exact = supported.find(c => c.toLowerCase() === want.toLowerCase()); + if (exact) return exact; + const base = want.split('-')[0].toLowerCase(); + const loose = supported.find(c => c.toLowerCase().split('-')[0] === base); + if (loose) return loose; + } + return 'en'; +} + +/** What the screen shows for an API error body, or null when the body is not + one that stops the whole page. + @param {unknown} body @param {string} [fallbackHost] the page's own address */ +export function screenFor(body, fallbackHost = '') { + const damage = readConfigDamage(body); + if (damage) { + const why = damage.reason === 'unreadable' ? 'configRecovery.whyUnreadable' : 'configRecovery.whyCorrupt'; + return { + title: 'configRecovery.title', + why: { key: why, vars: { file: damage.file } }, + safe: 'configRecovery.locked', + stepsTitle: 'configRecovery.stepsTitle', + steps: recoverySteps(damage), + still: 'configRecovery.stillDamaged', + logHint: true, + help: HELP_URL, + }; + } + const block = readHostBlock(body); + if (block) { + const host = block.host || fallbackHost; + return { + title: 'hostBlock.title', + why: { key: 'hostBlock.why', vars: { host } }, + safe: 'hostBlock.safe', + stepsTitle: 'hostBlock.stepsTitle', + steps: [ + { key: 'hostBlock.stepOpen' }, + { key: 'hostBlock.stepAllow', vars: { host } }, + { key: 'hostBlock.stepPassword' }, + { key: 'configRecovery.stepCheck' }, + ], + still: 'hostBlock.stillBlocked', + logHint: false, + help: HOST_HELP_URL, + }; + } + return null; +} diff --git a/public/js/config-recovery.js b/public/js/config-recovery.js new file mode 100644 index 0000000..a673c8c --- /dev/null +++ b/public/js/config-recovery.js @@ -0,0 +1,85 @@ +// @ts-check +import { esc, html, raw, setHtml } from '/js/html.js?v=c71f8903'; +import { LANGUAGES, initI18n, t } from '/js/i18n.js?v=1f1ea9c1'; +import { sanitizeI18nMarkup } from '/js/i18n-markup.js?v=8c90e1dd'; +import { fillNames, pickLanguage, screenFor } from '/js/config-recovery-logic.js?v=e6e24763'; + +let _shown = false; +export const recoveryShown = () => _shown; + +/** The screen an API error body calls for, or null. + @param {unknown} body */ +export const blockingScreenFor = body => screenFor(body, location.host); + +/** Replace the page with a screen that explains why the API refuses every + request, and how to fix it. Nothing else on the page may keep running. + @param {NonNullable>} screenText */ +export async function showBlockingScreen(screenText) { + _shown = true; + await initI18n( + pickLanguage( + navigator.languages || [navigator.language], + LANGUAGES.map(l => l.code), + ), + ); + const screen = document.createElement('div'); + screen.className = 'cfg-recovery'; + setHtml( + screen, + html`
+
+ +

+

+
`, + ); + const body = /** @type {HTMLElement} */ (screen.querySelector('.cfg-recovery-body')); + const btn = /** @type {HTMLButtonElement} */ (screen.querySelector('.cfg-recovery-btn')); + const status = /** @type {HTMLElement} */ (screen.querySelector('.cfg-recovery-status')); + const help = /** @type {HTMLElement} */ (screen.querySelector('.cfg-recovery-help')); + render(body, help, screenText); + btn.addEventListener('click', async () => { + btn.disabled = true; + status.textContent = ''; + try { + const res = await fetch('/api/auth/check', { cache: 'no-store' }); + const next = res.ok ? null : blockingScreenFor(await res.json().catch(() => null)); + if (!next) { + location.reload(); + return; + } + render(body, help, next); + status.textContent = t(next.still); + } catch { + status.textContent = t('home.apiDownTitle'); + } finally { + btn.disabled = false; + } + }); + document.body.replaceChildren(screen); + document.body.classList.add('ready'); + /** @type {HTMLElement | null} */ (body.querySelector('h1'))?.focus(); +} + +/** @param {HTMLElement} body @param {HTMLElement} help + @param {NonNullable>} s */ +function render(body, help, s) { + document.title = t(s.title); + setHtml( + body, + html`

${t(s.title)}

+

${named(s.why.key, s.why.vars)}

+

${t(s.safe)}

+

${t(s.stepsTitle)}

+
    + ${s.steps.map(step => html`
  1. ${named(step.key, step.vars)}
  2. `)} +
`, + ); + setHtml( + help, + html`${s.logHint ? html`${t('configRecovery.logHint')} ` : ''}${t('configRecovery.troubleshooting')}`, + ); +} + +/** @param {string} key @param {Record} [vars] */ +const named = (key, vars) => raw(fillNames(v => t(key, v), vars, sanitizeI18nMarkup, esc)); diff --git a/public/js/dashboard.js b/public/js/dashboard.js index c463ef2..7cf16bb 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -24,12 +24,13 @@ import { setUserText, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=b1cfbd45'; +} from '/js/utils.js?v=c5766a9d'; import { initFluidHover } from '/js/fluid-hover.js?v=cb886e86'; -import { initSpotlight } from '/js/spotlight.js?v=53fd6b55'; +import { initSpotlight } from '/js/spotlight.js?v=75f7fd04'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=1f1ea9c1'; -import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=42f45ac7'; +import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=706fc9a7'; +import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=389e0ed0'; import { sanitizeItemLinks } from '/js/link-url.js?v=54adb40f'; import { DOCK_MAX } from '/js/limits.js?v=31048a24'; import { @@ -40,7 +41,7 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=9bdba35c'; +} from '/js/ui.js?v=d9417e1e'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=9e6d9d4b'; import { formatNumber } from '/js/format-number.js?v=4a5ccef4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=aa52b1a3'; @@ -52,8 +53,8 @@ import { landingAfterSetup, restorePage, } from '/js/dashboard-logic.js?v=0d519f8b'; -import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=cd1cc453'; -import { jitter } from '/js/jitter.js?v=4eeef4c9'; +import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=5f478ebf'; +import { repeatJittered } from '/js/jitter.js?v=087a1fcf'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; @@ -596,7 +597,9 @@ function refreshBadges() { } async function pollBadges() { try { - const d = await (await fetch('/api/badges', { cache: 'no-store' })).json(); + const res = await fetch('/api/badges', { cache: 'no-store' }); + if (!res.ok) throw new Error(`badges ${res.status}`); + const d = await res.json(); for (const [id, v] of Object.entries(d)) { const { value, failed } = readBadgeUpdate(v); /* A failed item keeps its last value, marked stale. Overwriting it with @@ -621,7 +624,9 @@ async function pollBadges() { } async function pollHealth() { try { - const d = await (await fetch('/api/health', { cache: 'no-store' })).json(); + const res = await fetch('/api/health', { cache: 'no-store' }); + if (!res.ok) throw new Error(`health ${res.status}`); + const d = await res.json(); for (const [id, v] of Object.entries(d)) { bset(id, 'healthDetail', v); bset(id, 'health', v.unhealthy ? 1 : 0); @@ -658,17 +663,15 @@ function showSetupPrompt() { const err = q('#setup-err', ov); const setB = qi('#setup-set', ov); const skip = qi('#setup-skip', ov); - const dim = 'rgba(255,255,255,.1)'; /* A typo here locks the dashboard with no way back in. */ const matches = () => pw2.value !== '' && !passwordMismatch(pw.value, pw2.value); const sync = () => { const { score, labelKey, color, ok } = pwStrength(pw.value); bars.forEach((b, i) => { - b.style.background = pw.value && i < score ? color : dim; + b.style.background = pw.value && i < score ? color : ''; }); hint.textContent = pw.value && labelKey ? t(labelKey) : ''; - hint.style.color = color; const mismatch = pw2.value !== '' && passwordMismatch(pw.value, pw2.value); err.textContent = mismatch ? t('setup.mismatch') : ''; err.style.display = mismatch ? 'block' : 'none'; @@ -753,6 +756,11 @@ async function boot() { return; } authData = await authCheck.json(); + const screen = authCheck.ok ? null : blockingScreenFor(authData); + if (screen) { + await showBlockingScreen(screen); + return; + } if (authData.enabled && !authData.authenticated) { window.location.href = '/admin/'; return; @@ -839,11 +847,22 @@ async function boot() { folderGlyph: mkFolderGlyph, }); - /* The width the current layout was built at. */ + /* One rotation fires several of the events below. Rebuild only when the + space moved since the last build: each rebuild reloads every widget. */ let _mw = innerWidth; + let _saAt = ''; + /** @type {HTMLElement|null} */ + let saProbe = null; + const saSize = () => { + if (!saProbe) return ''; + const r = saProbe.getBoundingClientRect(); + return `${r.width}x${r.height}`; + }; + const spaceMoved = () => innerWidth !== _mw || saSize() !== _saAt; /* Mobile measures the viewport as it builds, so it waits for layout. */ const buildLayout = () => { _mw = innerWidth; + _saAt = saSize(); if (MOB) { document.body.classList.add('is-mob'); requestAnimationFrame(() => @@ -943,6 +962,7 @@ async function boot() { const probe = document.createElement('div'); probe.className = 'sa-probe'; document.body.appendChild(probe); + saProbe = probe; let _sat, _saH = -1, _saW = -1, @@ -960,7 +980,7 @@ async function boot() { } clearTimeout(_sat); _sat = setTimeout(() => { - if (MOB) buildLayout(); + if (MOB && spaceMoved()) buildLayout(); }, 100); }).observe(probe); } @@ -972,7 +992,7 @@ async function boot() { screen.orientation.addEventListener('change', () => { clearTimeout(_rt); _rt = setTimeout(() => { - if (MOB) buildLayout(); + if (MOB && spaceMoved()) buildLayout(); resampleBg(); }, 150); }); @@ -1009,17 +1029,7 @@ async function boot() { /* Jittered, so several open clients do not poll on the same tick. */ let _pollTimers = []; - const _repeat = (fn, base) => { - let h; - const tick = async () => { - try { - await fn(); - } catch {} - h = setTimeout(tick, jitter(base)); - }; - h = setTimeout(tick, Math.round(Math.random() * base)); - _pollTimers.push(() => clearTimeout(h)); - }; + const _repeat = (fn, base) => _pollTimers.push(repeatJittered(fn, base)); const pollConfig = async () => { try { @@ -1031,17 +1041,19 @@ async function boot() { } catch {} }; + const stopPolling = () => { + _pollTimers.forEach(clear => clear()); + _pollTimers = []; + }; + + /* A tab that boots hidden starts here and again when first shown. */ const startPolling = () => { + stopPolling(); _repeat(pollBadges, 20_000); _repeat(pollHealth, 30_000); _repeat(pollConfig, 15_000); }; - const stopPolling = () => { - _pollTimers.forEach(clear => clear()); - _pollTimers = []; - }; - /* Keep. Without it Safari leaves the tile scaled after a new tab opens. */ document.addEventListener('click', e => { if (!(e.target instanceof Element)) return; diff --git a/public/js/host-names.js b/public/js/host-names.js new file mode 100644 index 0000000..c3a4686 --- /dev/null +++ b/public/js/host-names.js @@ -0,0 +1,99 @@ +// @ts-check +/* Which addresses Stackyard answers on while no password is set. Keep it free + of the DOM and of imports: the API requires it. */ + +export const ALLOWED_HOSTS_MAX = 50; + +const LABEL = /^(?!-)[a-z0-9_-]{1,63}(? 300) return null; + s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//, '').replace(/\/.*$/, ''); + if (s.startsWith('[')) { + const end = s.indexOf(']'); + if (end < 0) return null; + const rest = s.slice(end + 1); + if (rest && !/^:\d{1,5}$/.test(rest)) return null; + const ip = s.slice(1, end); + return isIPv6(ip) ? ip : null; + } + if (isIPv6(s)) return s; + const m = s.match(/^([^:]+)(?::(\d{1,5}))?$/); + if (!m) return null; + const name = m[1].replace(/\.$/, ''); + if (IPV4.test(name)) return isIPv4(name) ? name : null; + if (name.length > 253 || !name.split('.').every(l => LABEL.test(l))) return null; + return name; +} + +/** @param {string} s */ +function isIPv4(s) { + const m = s.match(IPV4); + return !!m && m.slice(1).every(n => Number(n) <= 255); +} + +/** @param {string} s */ +function isIPv6(s) { + const bare = s.replace(/%[\w.-]+$/, ''); + if (!/^[0-9a-f:.]+$/.test(bare) || !bare.includes(':')) return false; + try { + return new URL(`http://[${bare}]/`).hostname.length > 2; + } catch { + return false; + } +} + +/** True for an IP address, localhost, a single-label name and a reserved local + name. These need no entry in the allowed list. + @param {string} hostname from hostnameOf */ +export function isLocalAddress(hostname) { + if (isIPv4(hostname) || isIPv6(hostname)) return true; + if (hostname === 'localhost' || !hostname.includes('.')) return true; + return LOCAL_SUFFIXES.some(s => hostname.endsWith(s)); +} + +/** The list as stored: host names only, deduplicated. Null when an entry is + not a host name or there are too many. + @param {unknown} value @returns {string[] | null} */ +export function normalizeHostList(value) { + if (!Array.isArray(value)) return null; + const out = []; + for (const entry of value) { + const name = hostnameOf(entry); + if (!name) return null; + if (!out.includes(name)) out.push(name); + } + return out.length > ALLOWED_HOSTS_MAX ? null : out; +} + +/** The first entry of a comma-separated list that is not a host name, or null. + @param {string} text @returns {string | null} */ +export function firstBadHost(text) { + for (const part of String(text || '').split(',')) { + const p = part.trim(); + if (p && !hostnameOf(p)) return p; + } + return null; +} + +/** @param {string} text @returns {string[]} */ +export function parseHostList(text) { + return ( + normalizeHostList( + String(text || '') + .split(',') + .map(p => p.trim()) + .filter(Boolean), + ) || [] + ); +} diff --git a/public/js/import-foreign.js b/public/js/import-foreign.js index 6097b16..1336e17 100644 --- a/public/js/import-foreign.js +++ b/public/js/import-foreign.js @@ -3,7 +3,7 @@ only: a widget in the source becomes a plain app tile, never a Stackyard widget. Keep this module free of the DOM and of the network. */ -import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=60a82419'; +import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=8389782f'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; export const SKIP = Object.freeze({ diff --git a/public/js/jitter.js b/public/js/jitter.js index caf7cea..7bfac4c 100644 --- a/public/js/jitter.js +++ b/public/js/jitter.js @@ -10,3 +10,22 @@ export function jitter(base) { if (!Number.isFinite(n) || n <= 0) return 0; return Math.round(n * (1 + (Math.random() * 2 - 1) * SPREAD)); } + +/** @param {() => unknown} fn @param {number} base milliseconds + @returns {() => void} stops the loop */ +export function repeatJittered(fn, base) { + let handle; + let stopped = false; + const tick = async () => { + try { + await fn(); + } catch {} + /* A stop during a call in flight must hold, or every stop leaks a loop. */ + if (!stopped) handle = setTimeout(tick, jitter(base)); + }; + handle = setTimeout(tick, Math.round(Math.random() * base)); + return () => { + stopped = true; + clearTimeout(handle); + }; +} diff --git a/public/js/listbox.js b/public/js/listbox.js index 4562747..c9f80df 100644 --- a/public/js/listbox.js +++ b/public/js/listbox.js @@ -6,10 +6,10 @@ Do not use popover. The top layer makes iOS collapse its browser toolbar, which exposes a strip of page canvas and misplaces the menu. */ -import { nextActiveIndex } from '/js/admin-logic.js?v=cbb7417d'; +import { nextActiveIndex } from '/js/admin-logic.js?v=fc7f0836'; import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa } from '/js/utils.js?v=b1cfbd45'; +import { qa } from '/js/utils.js?v=c5766a9d'; import { iconSvg } from '/js/icon-set.js?v=34af798f'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); diff --git a/public/js/modal.js b/public/js/modal.js index 7371999..6fa5597 100644 --- a/public/js/modal.js +++ b/public/js/modal.js @@ -114,30 +114,39 @@ export function confirmText({ title, text, confirmLabel, cancelLabel, destructiv return confirmModal({ title, body: lead, confirmLabel, cancelLabel, destructive }); } -/** A modal asking for one line of text, resolving to the trimmed value or null. +/** A modal asking for one line of text, resolving to the value or null. Never + trim a password. - @param {{ title: string, label: string, placeholder?: string, + @param {{ title: string, label: string, placeholder?: string, text?: string, + password?: boolean, destructive?: boolean, confirmLabel: string, cancelLabel: string }} opts @returns {Promise} */ -export function promptModal({ title, label, placeholder, confirmLabel, cancelLabel }) { +export function promptModal({ title, label, placeholder, text, password, destructive, confirmLabel, cancelLabel }) { return new Promise(resolve => { /** @type {string|null} */ let answer = null; const m = openModal({ title, onClose: () => resolve(answer) }); + if (text) { + const lead = document.createElement('p'); + lead.className = 'dlg-lead'; + lead.textContent = text; + m.body.appendChild(lead); + } const field = document.createElement('label'); field.className = 'dlg-field'; const cap = document.createElement('span'); cap.textContent = label; const input = document.createElement('input'); - input.type = 'text'; + input.type = password ? 'password' : 'text'; + if (password) input.autocomplete = 'current-password'; input.className = 'inp'; if (placeholder) input.placeholder = placeholder; field.append(cap, input); m.body.appendChild(field); const accept = () => { - const v = input.value.trim(); + const v = password ? input.value : input.value.trim(); if (!v) return; answer = v; m.close(); @@ -149,7 +158,7 @@ export function promptModal({ title, label, placeholder, confirmLabel, cancelLab } }; m.addAction(cancelLabel, 'bg sm', m.close); - m.addAction(confirmLabel, 'bp sm', accept); + m.addAction(confirmLabel, destructive ? 'bd-btn sm' : 'bp sm', accept); m.focus(input); }); } diff --git a/public/js/password-strength.js b/public/js/password-strength.js index 8cbf783..65fab5c 100644 --- a/public/js/password-strength.js +++ b/public/js/password-strength.js @@ -2,11 +2,9 @@ /* Returns a label key, not a display string. Keep this module free of an i18n import. */ -const DIM = 'rgba(255,255,255,.1)'; - /* Five scores, four labels. The index must clamp to the last entry. */ const LABEL_KEYS = ['pwStrength.weak', 'pwStrength.fair', 'pwStrength.good', 'pwStrength.strong']; -const COLORS = ['#ff9f0a', '#ffd60a', '#34c759', '#34c759']; +const COLORS = ['var(--warning)', 'var(--sy-yellow-hi)', 'var(--success)', 'var(--success)']; const BARS = 5; export const MIN_PASSWORD_LENGTH = 8; @@ -20,9 +18,9 @@ export function passwordMismatch(newPassword, confirmation) { @returns {{ score:number, labelKey:string, color:string, ok:boolean }} `labelKey` is '' when there is nothing to say. */ export function pwStrength(pw) { - if (!pw) return { score: 0, labelKey: '', color: DIM, ok: false }; + if (!pw) return { score: 0, labelKey: '', color: '', ok: false }; if (pw.length < MIN_PASSWORD_LENGTH) { - return { score: 1, labelKey: 'pwStrength.tooShort', color: '#ff453a', ok: false }; + return { score: 1, labelKey: 'pwStrength.tooShort', color: 'var(--danger)', ok: false }; } let score = 1; /* starts at 1 once length >= 8 */ if (pw.length >= 12) score++; diff --git a/public/js/spotlight.js b/public/js/spotlight.js index 82df046..75a3c0f 100644 --- a/public/js/spotlight.js +++ b/public/js/spotlight.js @@ -1,5 +1,5 @@ import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=cb886e86'; -import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=b1cfbd45'; +import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=c5766a9d'; import { t } from '/js/i18n.js?v=1f1ea9c1'; /* Attached to the window so a re-open can undo the previous one. */ diff --git a/public/js/ui.js b/public/js/ui.js index eae0432..569ed73 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -14,7 +14,7 @@ import { q, qa, setUserText, -} from '/js/utils.js?v=b1cfbd45'; +} from '/js/utils.js?v=c5766a9d'; import { t, currentLang } from '/js/i18n.js?v=1f1ea9c1'; import { toneForColor } from '/js/label-contrast.js?v=c1ac6fb8'; import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=349f4300'; @@ -484,7 +484,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { dotsEl.className = 'folder-dots dyn-dots-row'; css(dotsEl, { padding: `${Math.max(0, Math.round(18 * ptScale) - badgeOvh)}px 0 ${Math.round(4 * ptScale)}px`, - gap: Math.round(7 * ptScale) + 'px', + gap: Math.max(dotSz + 2, Math.min(24, Math.floor(pageW / pages.length))) - dotSz + 'px', }); dotEls = pages.map((_, i) => { const d = mk('div'); diff --git a/public/js/utils.js b/public/js/utils.js index 0564d51..1228e6f 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -306,14 +306,25 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla const jit = () => Math.round(base * (1 + (Math.random() * 2 - 1) * 0.15)); /* The handle is reassigned every tick. The cleanup must read the current one. */ + let missed = false; let timer = setTimeout( function tick() { - reload(); + if (document.hidden) missed = true; + else reload(); timer = setTimeout(tick, jit()); }, Math.round(Math.random() * base), ); - cleanups.push(() => clearTimeout(timer)); + const onShow = () => { + if (document.hidden || !missed) return; + missed = false; + reload(); + }; + document.addEventListener('visibilitychange', onShow); + cleanups.push(() => { + clearTimeout(timer); + document.removeEventListener('visibilitychange', onShow); + }); } /* An iframe swallows touches. Listening on its own document keeps interior diff --git a/public/js/widget-config-form.js b/public/js/widget-config-form.js index 2159967..469c9bd 100644 --- a/public/js/widget-config-form.js +++ b/public/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=1f1ea9c1'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=3870d0d0'; -import { createListbox } from '/js/listbox.js?v=6e7b7060'; -import { renderColorControl } from '/js/admin-color-control.js?v=844bdeb2'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=a81b9cbe'; +import { createListbox } from '/js/listbox.js?v=9a8ae607'; +import { renderColorControl } from '/js/admin-color-control.js?v=0d4d1038'; import { seedCarried, applyOptionSet, @@ -13,9 +13,9 @@ import { requiredFieldMissing, groupBounds, visibleFieldFlags, -} from '/js/admin-logic.js?v=cbb7417d'; +} from '/js/admin-logic.js?v=fc7f0836'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=f82486b2'; -import { qi } from '/js/utils.js?v=b1cfbd45'; +import { qi } from '/js/utils.js?v=c5766a9d'; const PE = ''; @@ -146,11 +146,13 @@ async function _fetchOptions(field, ctx) { if (!r.ok || d.error) { /* Carry the structured fields so the caller branches on `kind`, never on the message. */ - const err = /** @type {Error & { status?: number, kind?: string, detail?: Record }} */ ( - new Error(d.error || 'HTTP ' + r.status) - ); + const err = + /** @type {Error & { status?: number, kind?: string, code?: string, detail?: Record }} */ ( + new Error(d.error || 'HTTP ' + r.status) + ); err.status = r.status; if (typeof d.kind === 'string') err.kind = d.kind; + if (typeof d.code === 'string') err.code = d.code; if (d.detail && typeof d.detail === 'object') err.detail = d.detail; throw err; } diff --git a/public/js/widget-toolbox.js b/public/js/widget-toolbox.js index 8cfbe04..e1c5caa 100644 --- a/public/js/widget-toolbox.js +++ b/public/js/widget-toolbox.js @@ -3,7 +3,7 @@ import { esc, html, setHtml } from '/js/html.js?v=c71f8903'; import { isSafeLinkUrl } from '/js/link-url.js?v=54adb40f'; -import { jitter } from '/js/jitter.js?v=4eeef4c9'; +import { jitter } from '/js/jitter.js?v=087a1fcf'; import { errorState as _errorState, errorKind, errorCopy } from '/js/widget-error.js?v=7da9754b'; export { esc, html, setHtml }; @@ -344,6 +344,7 @@ export function poll(opts = {}) { lastData = null, timer = null; let paused = false; + let inFlight = false; let lastTick = 0; async function tick() { @@ -376,7 +377,13 @@ export function poll(opts = {}) { /* setTimeout, not setInterval. A slow fetch must not overlap the next one. */ async function loop() { - await tick(); + timer = null; + inFlight = true; + try { + await tick(); + } finally { + inFlight = false; + } if (stopped) return; /* Schedule nothing while hidden. Each tick reaches the user's own service, and browser throttling only slows that. */ @@ -396,12 +403,8 @@ export function poll(opts = {}) { if (stopped || paused || timer === null) return; clearTimeout(timer); const due = lastTick + intervalFor(lastData) * rate - Date.now(); - if (due <= 0) { - timer = null; - loop(); - } else { - timer = setTimeout(loop, jitter(due)); - } + if (due <= 0) loop(); + else timer = setTimeout(loop, jitter(due)); } _polls.add(onRate); @@ -415,7 +418,9 @@ export function poll(opts = {}) { } if (!paused) return; paused = false; - loop(); + /* A tick still in flight schedules the next one when it lands. A second + loop here would double the poll rate for good. */ + if (!inFlight) loop(); } /* poll() is unit-tested outside a browser, where there is no document. */ const canObserve = typeof document !== 'undefined' && typeof document.addEventListener === 'function'; diff --git a/public/widgets/backup/backup.html b/public/widgets/backup/backup.html index ee2bb5d..2790953 100644 --- a/public/widgets/backup/backup.html +++ b/public/widgets/backup/backup.html @@ -121,7 +121,7 @@
diff --git a/public/widgets/nowplaying/index.html b/public/widgets/nowplaying/index.html index b187619..226c52c 100644 --- a/public/widgets/nowplaying/index.html +++ b/public/widgets/nowplaying/index.html @@ -129,7 +129,7 @@