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(
${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`- ${named(step.key, step.vars)}
`)}
+
`,
+ );
+ 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 @@