mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-14 04:15:06 +08:00
1208 lines
69 KiB
JavaScript
1208 lines
69 KiB
JavaScript
'use strict';
|
||
'require baseclass';
|
||
'require rpc';
|
||
'require fs-fit as fit';
|
||
|
||
/* The Appearance axes THIS file owns (the controls that present them are fs-appearance.js, which
|
||
* appends a tab to the stock System -> System page — a theme owns no dispatcher node, see that
|
||
* file's header). The twenty-one are exactly the keys in AXIS_KEYS and the fields in
|
||
* snapshotAxes(), which is the list Save-as-default writes — if this number and that list
|
||
* disagree, the list is right.
|
||
* All client-side, instant, persisted in localStorage —
|
||
* no server, no reload — and head.ut's inline script re-applies them before paint, so a reload never
|
||
* flashes the wrong one; tools/axes.mjs holds those two copies to one contract, and it derives that
|
||
* contract from THIS file.
|
||
*
|
||
* ---- three layers, and the browser always wins ----
|
||
* The effective value of every axis is localStorage ?? router-default ?? built-in. The router
|
||
* default is Appearance -> Save as default (saveAsDefault below, written to /etc/config/footstrap
|
||
* and read back by the server into window.__fsSD); the built-in is a bare :root. So a NEW browser,
|
||
* incognito, or a cleared cache inherits the router default, but THIS browser's own choice — stored
|
||
* EXPLICITLY, see the next paragraph — overrides it, in either direction.
|
||
*
|
||
* ---- every applier stores its choice EXPLICITLY, and that is load-bearing ----
|
||
* Once a router default exists, "clear the key" no longer means "the built-in default" — it means
|
||
* "inherit whatever the router default is". So an applier that lsDel-ed on the default value could
|
||
* not express "I want the built-in, NOT the router default" (you could not turn a router-defaulted
|
||
* tint back off). Every axis therefore records the chosen value, including the off/default one, the
|
||
* way `layout` always has. lsDel is reserved for resetToSaved(), which drops back to the router
|
||
* default on purpose. */
|
||
/* A browser can REFUSE storage outright — "block all cookies" for this address in Chrome/Safari,
|
||
* dom.storage.enabled=false in Firefox, a partitioned WebView — and then every access throws. The
|
||
* three helpers below still swallow it, because an axis that cannot be remembered must still be
|
||
* allowed to APPLY; what they no longer do is keep quiet about it. Without this flag the page told a
|
||
* flat lie: each control took effect, nothing was written, current*() then read null and fell back
|
||
* to the router default, so matchesSavedDefault() was true and the Save button sat there DISABLED
|
||
* reading "Saved as default" while the page was painted in three axes the router default does not
|
||
* carry — and a reload dropped all of them. The Appearance tab asks this and says so once. */
|
||
let _lsBroken = false;
|
||
function storageBroken() { return _lsBroken; }
|
||
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { _lsBroken = true; return null; } }
|
||
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { _lsBroken = true; } }
|
||
function lsDel(k) { try { localStorage.removeItem(k); } catch (e) { _lsBroken = true; } }
|
||
|
||
/* A stored JSON ARRAY, or [] — the shape the two REMEMBERED LISTS use (the search palette's recent
|
||
* paths, the menu's open sections). lsGet above owns the try/catch around localStorage itself; this
|
||
* one is for JSON.parse over a value another tab may have corrupted, and for the Array guard that
|
||
* stops a stored object or string being spread into a list. Both callers had written the identical
|
||
* five lines, comment included; each still applies its own post-step (filter to strings / build a
|
||
* Set), which is the part that genuinely differs. */
|
||
function lsGetArr(k) {
|
||
try {
|
||
const a = JSON.parse(lsGet(k) || '[]');
|
||
return Array.isArray(a) ? a : [];
|
||
} catch (e) { return []; }
|
||
}
|
||
|
||
/* the router-wide defaults the server stamped (head.ut). Read at RUNTIME so current*() reports the
|
||
* effective default when this browser has no localStorage — the Appearance tab's controls then show what
|
||
* the page is actually painted as, not a phantom "auto". */
|
||
function sd(k) { try { return (window.__fsSD || {})[k]; } catch (e) { return undefined; } }
|
||
|
||
/* …and the write back. An applier that persists to the router must update the blob the SERVER
|
||
* stamped, or current*() would keep reporting the OLD router default until the next full load —
|
||
* matchesSavedDefault() then lies about whether there is anything left to save. Three appliers did
|
||
* this with the same guarded one-liner; the guard is for a document where `window` is locked down,
|
||
* exactly like sd() above. */
|
||
function setSD(field, val) { try { (window.__fsSD = window.__fsSD || {})[field] = val; } catch (e) {} }
|
||
|
||
/* ---- every axis owns its ROUTER DEFAULT, and nothing else may restate it ----
|
||
* `def()` is the sd() branch of current() alone — what the effective value would be with no
|
||
* localStorage. It is exposed because _resolvedDefault() needs exactly that branch and used to
|
||
* spell each one out a second time: two copies of the SAME validation, and the drift has no
|
||
* symptom. Disagree, and matchesSavedDefault() lies about the one thing the Save button IS —
|
||
* its own status (see there): it greys when there is something to save, or never greys at all,
|
||
* and nothing else in the UI would contradict it. */
|
||
function modeDefault() {
|
||
const d = sd('darkmode');
|
||
return (d === 'dark' || d === 'light') ? d : 'auto';
|
||
}
|
||
function currentMode() {
|
||
const s = lsGet('fs-darkmode');
|
||
if (s === 'true') return 'dark';
|
||
if (s === 'false') return 'light';
|
||
if (s === 'auto') return 'auto';
|
||
if (s === null) return modeDefault();
|
||
return 'auto';
|
||
}
|
||
/* ---- dark mode is announced in three dialects, because apps SNIFF for it ----
|
||
*
|
||
* An app with its own dark styles has to guess whether the page is dark, and there is no standard:
|
||
* apps read `data-theme="dark"` on :root (luci-app-justclash keys 21 rules off it), Bootstrap's
|
||
* `data-bs-theme` (luci-app-ssclash), or, failing both, the LUMINANCE of the body background
|
||
* (ssclash's fallback). Stamp all three for the same fact: before this, every one of justclash's
|
||
* [data-theme="dark"] rules was dead, so a dark page rendered its LIGHT fills.
|
||
*
|
||
* `data-darkmode` is the name the theme's OWN CSS keys off. The other two are OUTBOUND
|
||
* compatibility, like the `--*-color-*` export tier: nothing in `styles/` may read them, and
|
||
* tools/axes.mjs fails the build if it does. */
|
||
function stampDark(root, dark) {
|
||
root.setAttribute('data-darkmode', dark ? 'true' : 'false');
|
||
root.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||
root.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
|
||
}
|
||
|
||
const _mqDark = window.matchMedia('(prefers-color-scheme: dark)');
|
||
|
||
/* The one expression for "is this page dark right now", so the applier, the OS listener and the
|
||
* guard below cannot disagree about it — and all three really do call it now. Only the guard did:
|
||
* the other two spelled the same condition out again, which is the drift this exists to prevent,
|
||
* sitting three lines under a comment claiming it could not happen. */
|
||
function intendedDark() {
|
||
const m = currentMode();
|
||
return m === 'dark' || (m === 'auto' && _mqDark.matches);
|
||
}
|
||
|
||
function applyMode(val) {
|
||
const root = document.documentElement;
|
||
/* 'auto' is stored EXPLICITLY (not lsDel), so it overrides a router default of dark/light —
|
||
* otherwise a router defaulted to dark could never be set back to "follow the OS" here. */
|
||
if (val === 'auto') lsSet('fs-darkmode', 'auto');
|
||
else lsSet('fs-darkmode', val === 'dark' ? 'true' : 'false');
|
||
/* AFTER the store, so intendedDark() reads the choice just made — which is what lets the one
|
||
* expression serve here too, instead of a second copy spelled in terms of `val`. */
|
||
stampDark(root, intendedDark());
|
||
}
|
||
|
||
/* ---- the three dialects are PUBLISHED, so third parties write them too ----
|
||
*
|
||
* Announcing dark mode in a vocabulary apps understand is what makes them follow the page — and it
|
||
* is exactly why an app reaches for the same attribute. `luci-app-openclash` stamps
|
||
* `data-darkmode="true"` straight onto :root from seven of its templates (config_editor.htm:215,306,
|
||
* select_git_cdn.htm:114, config_edit.htm:259, tblsection.htm:479, sub_info_show.htm:52), gated on
|
||
* its own isDarkBackground() (openclash/js/common.js:12) — which consults
|
||
* `matchMedia('(prefers-color-scheme: dark)')` BEFORE it ever looks at the body's real background.
|
||
*
|
||
* So a user who explicitly chose LIGHT here, on an OS set to dark, gets the whole theme flipped to
|
||
* dark by opening an OpenClash page. Reproduced on the router: data-darkmode false -> true, page
|
||
* background rgb(246,248,250) -> rgb(28,33,40). Their explicit choice, lost, silently, to their OS
|
||
* setting, through someone else's package. select_git_cdn.htm:117's removeAttribute is the mirror
|
||
* hazard: it deletes the attribute head.ut writes as 'false'.
|
||
*
|
||
* No cascade trick can answer this — it is a DOM write, not a rule. So watch the attributes we own
|
||
* and restate the truth. Nothing else is guarded: the other axes (data-layout, data-palette,
|
||
* data-accent, data-tint) are PRIVATE to this theme, no app has a reason to know them, and a survey
|
||
* of ten shipping packages found none that writes any of them. The published trio is the surface
|
||
* precisely because it is published.
|
||
*
|
||
* This does not fight the app's intent, it corrects a wrong premise: when the page really is dark,
|
||
* OpenClash's write AGREES with ours and the guard never fires — the compare is what makes it inert
|
||
* in the common case, and what stops it looping on its own restamp. It also cannot ping-pong: our
|
||
* write produces a mutation, the callback re-runs, the values now match, it returns. */
|
||
function guardDarkStamp() {
|
||
const root = document.documentElement;
|
||
const check = () => {
|
||
const dark = intendedDark();
|
||
if (root.getAttribute('data-darkmode') === (dark ? 'true' : 'false') &&
|
||
root.getAttribute('data-theme') === (dark ? 'dark' : 'light') &&
|
||
root.getAttribute('data-bs-theme') === (dark ? 'dark' : 'light')) return;
|
||
stampDark(root, dark);
|
||
};
|
||
/* An app's inline <script> runs while its template is still being parsed — long before this
|
||
* module is fetched — so by now the attribute can already be wrong. Observing alone would never
|
||
* see that mutation: check first, then watch. */
|
||
check();
|
||
new MutationObserver(check).observe(root, {
|
||
attributes: true,
|
||
attributeFilter: ['data-darkmode', 'data-theme', 'data-bs-theme']
|
||
});
|
||
}
|
||
/* "Auto" means follow the OS — it only did so at page load, so an OS flipping to dark on its
|
||
* own schedule left the open page in light until a reload. Only follows when the effective mode
|
||
* is auto: an explicit browser choice, or an explicit router default with no browser override. */
|
||
_mqDark.addEventListener('change', () => {
|
||
if (currentMode() === 'auto') {
|
||
const root = document.documentElement;
|
||
stampDark(root, intendedDark());
|
||
}
|
||
});
|
||
/* Corner radius: the card radius (0–20px) as an inline --fs-radius-base on :root; 02-tokens derives
|
||
* every other radius from it, so surfaces round in step. head.ut pre-paints it, and tools/axes.mjs
|
||
* holds JS/CSS/head to this one number — hence the named const. The axis itself is a propAxis (below),
|
||
* the same shape as tint strength. */
|
||
const FS_RADIUS_DEFAULT = 12;
|
||
|
||
/* ---- the four axis SHAPES, each written once ------------------------------------------------
|
||
*
|
||
* Fifteen of the axes are four shapes, so the shape lives in a factory and each instance is one
|
||
* line: enumAxis (pattern ink), colorAxis (tint, accent, good, warn, danger), surfaceAxis (cards,
|
||
* controls, bar, borders), propAxis (rounding, tint strength, photo dim, pattern size, pattern
|
||
* strength). All keep the same
|
||
* contract: `current()` is localStorage ?? def(), `def()` is the router default alone, `apply()`
|
||
* stores the choice EXPLICITLY (see the header — lsDel would mean "inherit the router default", which
|
||
* is not what picking the built-in means). None use `this`: every export below is a DETACHED method
|
||
* reference (`const currentTint = TINT.current`), and a `this` in here would throw the moment one was
|
||
* called.
|
||
*
|
||
* Each factory takes its localStorage key as the FIRST argument, and tools/axes.mjs matches the call
|
||
* by its literal args — that scan is how a key reaches the gate at all, since an axis built by a
|
||
* factory has no lsGet('fs-…') call site to find.
|
||
*
|
||
* The remaining axes stay separate; each has a quirk a shared table would need an option for.
|
||
* `mode` stores a value it does not apply (tri-state → matchMedia) and owns an MQL listener; `layout`
|
||
* reads the ATTRIBUTE (the server-migrated default); `wallpaper` and `density` are three-valued;
|
||
* `palette` outgrew the two-value shape when the third one landed; `autoCollapse` has no :root
|
||
* attribute at all. */
|
||
|
||
/* A two-value axis: `on` is stamped as the attribute's VALUE, `off` is a bare :root (no attribute).
|
||
* Palette and wallpaper were this shape twice over — current() and apply() agreed line for line
|
||
* down to the stray-value fallthrough, and the two halves of `palette` had already drifted APART in
|
||
* the file (current() at the top, apply() 100 lines below). */
|
||
function enumAxis(key, attr, on, off) {
|
||
/* 'fs-pattern-ink' -> 'pattern_ink', the window.__fsSD field. The UNDERSCORE is the whole
|
||
* point: the localStorage key is hyphenated and the uci option is not, so a bare slice(3)
|
||
* answered 'pattern-ink' — a field head.ut never emits — and sd() returned undefined forever.
|
||
* The axis then reports the BUILT-IN default no matter what the router saved: the Ink control
|
||
* shows Theme while head.ut has already pre-painted Original, matchesSavedDefault() is false
|
||
* with nothing touched, and pressing Save-as-default writes the built-in over the admin's
|
||
* value. Nothing catches it — see the same trap named in propAxis below, which is why THAT
|
||
* factory takes the field name explicitly. */
|
||
const sdKey = key.slice(3).replace(/-/g, '_');
|
||
const def = () => (sd(sdKey) === on ? on : off);
|
||
return {
|
||
def,
|
||
current() {
|
||
const s = lsGet(key);
|
||
if (s === on) return on;
|
||
if (s === off) return off;
|
||
if (s === null) return def();
|
||
return off; /* a stray value reads as the built-in default */
|
||
},
|
||
apply(val) {
|
||
const root = document.documentElement;
|
||
const isOn = (val === on);
|
||
lsSet(key, isOn ? on : off);
|
||
if (isOn) root.setAttribute(attr, on);
|
||
else root.removeAttribute(attr);
|
||
}
|
||
};
|
||
}
|
||
|
||
/* A COLOUR axis. Five of them — Tint (the canvas), Accent (the UI colour) and the three status
|
||
* colours Good/Warn/Danger — are one axis pointed at five tokens: same validation, same "0 is off",
|
||
* same off path, same load-bearing ORDERING rule (set the custom property BEFORE the attribute, or
|
||
* a fresh load paints one frame in the previous colour). That rule is exactly what gets fixed in one
|
||
* copy and not the other, so it lives here once.
|
||
*
|
||
* A value is one of THREE things, and the attribute's value says which (03-palettes.css matches on
|
||
* it):
|
||
*
|
||
* 0 off — no attribute, the palette exactly as it shipped.
|
||
* 1–360 a HUE, set with the slider: CSS rotates the palette's own colour through
|
||
* oklch(from … l c H), so lightness and chroma — and therefore every contrast
|
||
* margin the palette was measured at — are the palette's, not the user's.
|
||
* '#rrggbb' a COLOUR, typed or picked in the colour field: stamped inline on :root as the live
|
||
* token, exactly as entered. The ink over it is derived from its lightness in CSS.
|
||
*
|
||
* Both live in ONE localStorage key rather than a colour key beside a hue key, because they are one
|
||
* question with two ways of answering it: two keys would need a third to say which is in effect, and
|
||
* that third is the one a pre-paint script forgets. `hueProp` carries the degrees for the rotation,
|
||
* `colorProp` is the live token a hex value overwrites; a hue value clears the second and a hex
|
||
* value clears the first, so the two modes can never both be half-applied. */
|
||
const FS_HEX_RE = /^#[0-9a-f]{6}$/i;
|
||
/* 0 | 1..360 | '#rrggbb', from anything — localStorage (always a string), the router default (a
|
||
* uci string, or a NUMBER from a config written before the axes took colours) or a caller. Anything
|
||
* unrecognised reads as off, which is the built-in default and the one safe answer. */
|
||
function normColor(v) {
|
||
if (typeof v === 'number') return (v >= 1 && v <= 360) ? v : 0;
|
||
if (typeof v !== 'string') return 0;
|
||
const s = v.trim();
|
||
if (FS_HEX_RE.test(s)) return s.toLowerCase();
|
||
const h = parseInt(s, 10);
|
||
return (h >= 1 && h <= 360) ? h : 0;
|
||
}
|
||
function colorAxis(key, attr, hueProp, colorProp) {
|
||
/* 'fs-tint' -> 'tint', the window.__fsSD field. Every colour key happens to be one word, so
|
||
* the hyphen fold changes nothing today — it is here because the day one is not, the failure
|
||
* is silent in both directions (see enumAxis above). */
|
||
const sdKey = key.slice(3).replace(/-/g, '_');
|
||
const def = () => normColor(sd(sdKey));
|
||
return {
|
||
def,
|
||
current() {
|
||
const raw = lsGet(key);
|
||
return (raw !== null) ? normColor(raw) : def();
|
||
},
|
||
apply(val) {
|
||
const root = document.documentElement;
|
||
const v = normColor(val);
|
||
/* stored EXPLICITLY, including 0=off, so dragging to off overrides a router default
|
||
* colour instead of falling back to it. */
|
||
lsSet(key, String(v));
|
||
if (!v) {
|
||
root.removeAttribute(attr);
|
||
root.style.removeProperty(hueProp);
|
||
root.style.removeProperty(colorProp);
|
||
} else if (typeof v === 'number') {
|
||
root.style.removeProperty(colorProp);
|
||
/* the hue FIRST, then the attribute that switches the rotation on — the other
|
||
* order paints one frame in the previous colour on a fresh load. */
|
||
root.style.setProperty(hueProp, String(v));
|
||
root.setAttribute(attr, 'hue');
|
||
} else {
|
||
root.style.removeProperty(hueProp);
|
||
root.style.setProperty(colorProp, v);
|
||
root.setAttribute(attr, 'hex');
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
/* A numeric slider axis that sets an INLINE custom property and NO attribute: rounding (the card
|
||
* radius) and tint strength are the same shape. Both validate to [min,max], store the choice
|
||
* EXPLICITLY — including the default, so it overrides a router default (see the header; lsDel would
|
||
* mean "inherit") — and remove the property AT the default, so 02-tokens' own value shows through.
|
||
* They differ ONLY in how the number formats onto the property (px vs a 0..2 multiplier), so that is
|
||
* the one argument that varies. The sd() field name is passed in explicitly because ONE instance
|
||
* needs a RENAME rather than a spelling: 'fs-radius' -> rounding. The other four would fall out of
|
||
* the same hyphen fold enumAxis and colorAxis do ('fs-tint-strength' -> tint_strength), but a
|
||
* factory that is right for four keys out of five is the trap those two walked into. */
|
||
function propAxis(key, sdKey, prop, min, max, dfl, fmt) {
|
||
const inRange = (n) => (typeof n === 'number' && n >= min && n <= max);
|
||
const def = () => { const d = sd(sdKey); return inRange(d) ? d : dfl; };
|
||
return {
|
||
def,
|
||
current() {
|
||
const raw = lsGet(key);
|
||
if (raw !== null) { const v = parseInt(raw, 10); return inRange(v) ? v : dfl; }
|
||
return def();
|
||
},
|
||
apply(n) {
|
||
const root = document.documentElement;
|
||
const v = Math.max(min, Math.min(max, n | 0));
|
||
lsSet(key, String(v));
|
||
if (v === dfl) root.style.removeProperty(prop);
|
||
else root.style.setProperty(prop, fmt(v));
|
||
}
|
||
};
|
||
}
|
||
|
||
/* Palette: footstrap (GitHub colours) is the default = bare :root; every other colourway is an
|
||
* opt-in data-palette value. Colourway blocks live in styles/03-palettes.css.
|
||
*
|
||
* This was the two-valued enumAxis shape while there were exactly two palettes, and stopped being
|
||
* able to express the set the moment a third arrived — an enumAxis has one `on` name and reads
|
||
* EVERY other stored string, including a real palette, as the default. So it is the
|
||
* wallpaper/density shape now: a list of the non-default values, which is what VALIDATES a stored
|
||
* value. A name added to the CSS and not to this array is one head.ut pre-paints and the live
|
||
* applier then rejects — the page paints it and the first touch of any other control takes it
|
||
* away.
|
||
*
|
||
* Legacy 'rvht'/'roman'/'github' are migrated to explicit values by head.ut before paint, so they
|
||
* never reach currentPalette() on a loaded page; the stray fallthrough covers them anyway. */
|
||
const PALETTES = [ 'hicontrast', 'bootstrap', '2020' ]; /* the non-default values; 'footstrap' = bare :root */
|
||
function paletteDefault() {
|
||
const d = sd('palette');
|
||
return (PALETTES.indexOf(d) >= 0) ? d : 'footstrap';
|
||
}
|
||
function currentPalette() {
|
||
const s = lsGet('fs-palette');
|
||
if (PALETTES.indexOf(s) >= 0) return s;
|
||
if (s === 'footstrap') return 'footstrap';
|
||
if (s === null) return paletteDefault();
|
||
return 'footstrap'; /* a stray value reads as the built-in default */
|
||
}
|
||
function applyPalette(val) {
|
||
const root = document.documentElement;
|
||
const v = (PALETTES.indexOf(val) >= 0) ? val : 'footstrap';
|
||
/* stored explicitly (including 'footstrap'), so it overrides a router default — see the header */
|
||
lsSet('fs-palette', v);
|
||
if (v === 'footstrap') root.removeAttribute('data-palette');
|
||
else root.setAttribute('data-palette', v);
|
||
}
|
||
|
||
/* Wallpaper is a MULTI-value axis and its own concern (composes with either palette): off (bare
|
||
* canvas), pattern (the admin-uploaded SVG, tiled and recoloured — 15-wallpaper.css) or file (the
|
||
* admin-uploaded photo, 16-login-bg.css). It is not the enumAxis shape (that is two-valued) —
|
||
* data-wallpaper carries the VALUE or is absent for 'off'. BOTH images are router-side
|
||
* (currentPattern / currentLoginBg below); this axis only decides whether THIS browser paints one,
|
||
* so a router-wide backdrop comes from Save-as-default, including the pre-login page.
|
||
*
|
||
* The list is what VALIDATES a stored value, so a value added to the CSS and not to this array is
|
||
* one head.ut pre-paints and the live applier then rejects: the page would paint it and the first
|
||
* touch of any other control would take it away. Adding one means this line, the head.ut whitelist,
|
||
* the Wallpaper select in fs-appearance.js and the rules in 15-wallpaper.css.
|
||
*
|
||
* A router upgrading from a version with the downloaded `cats`/`dinos` doodles reads its stored
|
||
* value here, finds it is not in the list, and falls to 'off' — the files those named are not in
|
||
* the package and are no longer fetched, so painting them was never an option. */
|
||
const WALLPAPERS = [ 'pattern', 'file' ]; /* the non-off values; 'off' = bare :root */
|
||
function wallpaperDefault() {
|
||
const d = sd('wallpaper');
|
||
return (WALLPAPERS.indexOf(d) >= 0) ? d : 'off';
|
||
}
|
||
function currentWallpaper() {
|
||
const s = lsGet('fs-wallpaper');
|
||
if (WALLPAPERS.indexOf(s) >= 0) return s;
|
||
if (s === 'off') return 'off';
|
||
if (s === null) return wallpaperDefault();
|
||
return 'off'; /* a stray value reads as the built-in default */
|
||
}
|
||
|
||
/* Density: how much AIR the UI uses — Compact / Normal / Large. A three-value axis like wallpaper
|
||
* (data-density carries 'compact'|'large', or is absent for the Normal default), and it is a pure
|
||
* TOKEN axis: 02-tokens.css multiplies the type and space ladders by two numbers and every size in
|
||
* the theme follows, because every size reads one of those ladders. Nothing else changes — no
|
||
* layout switch, no re-render.
|
||
*
|
||
* The one thing it must do beyond stamping the attribute is re-run the MEASURED decisions:
|
||
* `fitChrome` (does the menu still fit beside the brand?), `fitTables` (does this table still fit
|
||
* un-carded?) and `fitShell` (is the content column still readable beside the sidebar?) all
|
||
* measured the OLD metrics. Compact makes more fit and Large less, so without this the bar stays
|
||
* stacked — or worse, stays unstacked and overflows — until the next resize. */
|
||
const DENSITIES = [ 'compact', 'large' ]; /* the two non-default values; 'normal' = bare :root */
|
||
function densityDefault() {
|
||
const d = sd('density');
|
||
return (DENSITIES.indexOf(d) >= 0) ? d : 'normal';
|
||
}
|
||
function currentDensity() {
|
||
const s = lsGet('fs-density');
|
||
if (DENSITIES.indexOf(s) >= 0) return s;
|
||
if (s === 'normal') return 'normal';
|
||
if (s === null) return densityDefault();
|
||
return 'normal'; /* a stray value reads as the built-in default */
|
||
}
|
||
function applyDensity(val) {
|
||
const root = document.documentElement;
|
||
const v = (DENSITIES.indexOf(val) >= 0) ? val : 'normal';
|
||
/* stored explicitly (including 'normal'), so it overrides a router default — see the header */
|
||
lsSet('fs-density', v);
|
||
if (v === 'normal') root.removeAttribute('data-density');
|
||
else root.setAttribute('data-density', v);
|
||
fit.schedule();
|
||
}
|
||
function applyWallpaper(val) {
|
||
const root = document.documentElement;
|
||
const v = (WALLPAPERS.indexOf(val) >= 0) ? val : 'off';
|
||
/* stored explicitly (including 'off'), so it overrides a router default — see the header */
|
||
lsSet('fs-wallpaper', v);
|
||
if (v === 'off') root.removeAttribute('data-wallpaper');
|
||
else root.setAttribute('data-wallpaper', v);
|
||
}
|
||
|
||
/* Background-tint axis: the CANVAS the cards float on (--fs-bg), so a whole install reads as
|
||
* green/violet/amber and you can tell which router a tab — or a screenshot in a ticket — belongs
|
||
* to. Cards, chrome and the status colours keep the palette's values: the cue colours the paper,
|
||
* not the UI. On a hue it is mixed in CSS (:root[data-tint="hue"] + an inline --fs-tint-h; the TINT
|
||
* block in 03-palettes.css explains why that stays contrast-safe on every hue); on a hex it IS the
|
||
* canvas. 0 IS "OFF", not "red": a hue wheel wraps, so one end of the slider is free for the off
|
||
* state a colour axis otherwise has no room for. head.ut pre-paints it. */
|
||
const TINT = colorAxis('fs-tint', 'data-tint', '--fs-tint-h', '--fs-bg');
|
||
const currentTint = TINT.current, applyTint = TINT.apply;
|
||
|
||
/* Accent axis: the UI accent (solid buttons, toggle knobs, range sliders, focus rings, accented
|
||
* links) while canvas, cards and the status colours stay put — the tint colours the paper, this
|
||
* colours the CHROME. On a hue, CSS rotates --fs-accent via oklch(from … l c H), keeping the
|
||
* palette's lightness and chroma so --fs-on-accent stays legible without being recomputed; on a hex
|
||
* the ink IS recomputed, from the entered colour's lightness (03-palettes.css). 0 = off (the
|
||
* palette's designed accent), same rationale as the tint. head.ut pre-paints it. */
|
||
const ACCENT = colorAxis('fs-accent', 'data-accent', '--fs-accent-h', '--fs-accent');
|
||
const currentAccent = ACCENT.current, applyAccent = ACCENT.apply;
|
||
|
||
/* The three STATUS colours, the same axis pointed at --fs-good / --fs-warn / --fs-danger. They are
|
||
* separate axes rather than one "status" knob because they carry separate MEANINGS: an admin who
|
||
* wants a calmer red has no reason to move the green with it, and every derived tint
|
||
* (-soft/-fill/-line/-line-hi, the callouts, the diff blocks, the port speeds) is a color-mix() OF
|
||
* the role, so each follows its own axis with nothing else to update.
|
||
*
|
||
* They are exposed to the same recolouring as the accent and are NOT protected from it: a status
|
||
* colour is information, and an admin who paints Danger green has said so deliberately. What the
|
||
* theme owes them is the ink over the fill staying readable, which the hex-mode derivation in
|
||
* 03-palettes.css does, and the contrast readout the Appearance page draws beside each field. */
|
||
/* ---- the SURFACE axes: the sheet the UI is drawn ON, rather than the marks on it ----------
|
||
*
|
||
* Accent and the status colours are FIGURES: small, saturated, and each is ink on a surface whose
|
||
* contrast the theme can then derive. These four are the surfaces themselves — the cards, the
|
||
* chrome, the inset controls and the hairlines between them — and they behave differently enough
|
||
* that they are their own factory rather than four more colorAxis instances:
|
||
*
|
||
* - There is no hue mode. Rotating the hue of a near-white card keeps its chroma, which is
|
||
* ~0.003: every angle of the wheel produces the same white. The Tint axis is what colours a
|
||
* surface by hue, and it does it by SETTING a chroma rather than rotating one.
|
||
* - There is no derived ink. What reads on these is --fs-text, a palette token these axes must
|
||
* not move; the Appearance page reports the contrast each choice lands at instead.
|
||
* - They therefore need no attribute at all: an inline custom property on :root is the whole
|
||
* mechanism, and every derived token follows for free because each is a color-mix() of the
|
||
* one this sets (--fs-glass and --fs-bar-bg from --fs-panel, the hairline ladder from
|
||
* --fs-border). That is also why --fs-bar-bg is a surface of its own here: it is derived from
|
||
* --fs-panel and an admin who wants a dark chrome over light cards has to be able to say so.
|
||
*
|
||
* Off is lsSet('0'), not a deleted key, for the reason every other axis stores its default
|
||
* explicitly: once a router default exists, clearing the key means "inherit it". */
|
||
function surfaceAxis(key, sdKey, prop) {
|
||
const norm = (v) => {
|
||
const s = (typeof v === 'string') ? v.trim().toLowerCase() : '';
|
||
return FS_HEX_RE.test(s) ? s : 0;
|
||
};
|
||
const def = () => norm(sd(sdKey));
|
||
return {
|
||
def,
|
||
current() {
|
||
const raw = lsGet(key);
|
||
return (raw !== null) ? norm(raw) : def();
|
||
},
|
||
apply(val) {
|
||
const v = norm(val);
|
||
lsSet(key, String(v));
|
||
if (v) document.documentElement.style.setProperty(prop, v);
|
||
else document.documentElement.style.removeProperty(prop);
|
||
}
|
||
};
|
||
}
|
||
const CARD = surfaceAxis('fs-card', 'card', '--fs-panel-base');
|
||
const currentCard = CARD.current, applyCard = CARD.apply;
|
||
const CONTROL = surfaceAxis('fs-control', 'control', '--fs-panel2-base');
|
||
const currentControl = CONTROL.current, applyControl = CONTROL.apply;
|
||
const BAR = surfaceAxis('fs-bar', 'bar', '--fs-bar-bg');
|
||
const currentBar = BAR.current, applyBar = BAR.apply;
|
||
const LINE = surfaceAxis('fs-line', 'line', '--fs-border-base');
|
||
const currentLine = LINE.current, applyLine = LINE.apply;
|
||
|
||
|
||
const GOOD = colorAxis('fs-good', 'data-good', '--fs-good-h', '--fs-good');
|
||
const currentGood = GOOD.current, applyGood = GOOD.apply;
|
||
const WARN = colorAxis('fs-warn', 'data-warn', '--fs-warn-h', '--fs-warn');
|
||
const currentWarn = WARN.current, applyWarn = WARN.apply;
|
||
const DANGER = colorAxis('fs-danger', 'data-danger', '--fs-danger-h', '--fs-danger');
|
||
const currentDanger = DANGER.current, applyDanger = DANGER.apply;
|
||
|
||
/* Rounding: the propAxis instance (default const + rationale up top). --fs-radius-base in px. */
|
||
const RADIUS = propAxis('fs-radius', 'rounding', '--fs-radius-base', 0, 20, FS_RADIUS_DEFAULT, (v) => (v + 'px'));
|
||
const currentRadius = RADIUS.current, applyRadius = RADIUS.apply, radiusDefault = RADIUS.def;
|
||
|
||
/* Layout axis: horizontal top bar (the default) vs vertical sidebar. ONE template, ONE renderer — CSS
|
||
* morphs the chrome off :root[data-layout] (head.ut pre-paints it), and toggling re-renders
|
||
* NOTHING: the DOM serves both, and menu-footstrap.js's MutationObserver on data-layout folds the
|
||
* accordion into dropdowns / restores it.
|
||
*
|
||
* Read the ATTRIBUTE, not localStorage: head.ut stamps it server-side (from the router default) and
|
||
* the pre-paint script overrides it from localStorage, so it always carries an explicit value.
|
||
* localStorage would report 'sidebar' on a router whose default is 'top' until the user first
|
||
* touched the toggle. */
|
||
function currentLayout() {
|
||
return document.documentElement.getAttribute('data-layout') === 'top' ? 'top' : 'sidebar';
|
||
}
|
||
function isTopLayout() {
|
||
return currentLayout() === 'top';
|
||
}
|
||
function applyLayout(val) {
|
||
const layout = (val === 'top') ? 'top' : 'sidebar';
|
||
/* ALWAYS an explicit value, never a removed attribute: every layout rule matches data-layout
|
||
* POSITIVELY (='sidebar' / ='top'), and a migrated/defaulted router carries a server default
|
||
* that lsDel would let re-assert on the next load, so localStorage must record the choice. */
|
||
lsSet('fs-layout', layout);
|
||
document.documentElement.setAttribute('data-layout', layout);
|
||
/* the bar and the column have different room for the menu: re-take the fits-on-one-row
|
||
* measurement. Nothing else re-renders. */
|
||
fit.schedule();
|
||
}
|
||
|
||
/* Sidebar accordion: auto-collapse on = one section open at a time; off (default) they stack.
|
||
* Only meaningful for the expanded sidebar — rail flyouts and the mobile bar are always
|
||
* exclusive. Read by menu-footstrap.js. */
|
||
function autoCollapseDefault() {
|
||
return sd('autocollapse') === 'on';
|
||
}
|
||
function currentAutoCollapse() {
|
||
const s = lsGet('fs-menu-autocollapse');
|
||
if (s === 'true') return true;
|
||
if (s === 'false') return false;
|
||
if (s === null) return autoCollapseDefault();
|
||
return false;
|
||
}
|
||
function applyAutoCollapse(val) {
|
||
const on = (val === 'on');
|
||
/* stored explicitly ('false' for off, not lsDel) so it overrides a router default of 'on' */
|
||
lsSet('fs-menu-autocollapse', on ? 'true' : 'false');
|
||
|
||
/* Switching it on with several sections unfolded leaves the menu in a state the setting says is
|
||
* impossible, so somebody must fold them — but not this module. It owns storage; the menu owns
|
||
* every piece of the open/closed state (the `.open` class, the trigger's aria-expanded, the
|
||
* remembered "keep open" set), and it opens and closes exclusively through setOpen(), which is
|
||
* what keeps the class and the aria agreeing. Reaching in from here with a raw classList.remove
|
||
* satisfied the class and left the aria saying expanded — the exact disagreement setOpen exists
|
||
* to prevent — and then relied on this event to have the menu repair what we had just broken.
|
||
* One operation, one owner: say what changed and let the menu apply it. */
|
||
document.dispatchEvent(new CustomEvent('fs-autocollapse', { detail: { on } }));
|
||
}
|
||
|
||
/* The sidebar rail's collapsed flag. The BUTTON that flips it is chrome (fs-chrome.js); only the
|
||
* stored state belongs to the preference layer.
|
||
* NOT part of the router-wide defaults — it is a transient chrome collapse, not an appearance
|
||
* choice, so it is absent from snapshotAxes()/resetToSaved() below. */
|
||
function applyRail(on) {
|
||
const root = document.documentElement;
|
||
if (on) { root.setAttribute('data-rail', 'true'); lsSet('fs-rail', 'true'); }
|
||
else { root.removeAttribute('data-rail'); lsDel('fs-rail'); }
|
||
}
|
||
function currentRail() {
|
||
return document.documentElement.getAttribute('data-rail') === 'true';
|
||
}
|
||
|
||
/* ---- Save as default: write the current EFFECTIVE axes to /etc/config/footstrap ----
|
||
* The scoped rpcd ACL (config 'footstrap' only) lets the logged-in admin's session set + commit
|
||
* those options; rpcd validates the config/section/option names, so no value reaches a shell and
|
||
* there is no injection surface. The server reads them back on the next load and the sanitiser in
|
||
* head.ut clamps every one before it becomes window.__fsSD.
|
||
*
|
||
* snapshotAxes() reads the EFFECTIVE values (currentLayout()/currentMode()/… already fold in this
|
||
* browser's localStorage), so "Save as default" captures exactly what the user sees. It does NOT
|
||
* touch localStorage — this browser keeps overriding, which is the point: the saved default is for
|
||
* OTHER browsers/devices. resetToSaved() is the escape hatch that drops this browser back onto it. */
|
||
const AXIS_KEYS = [
|
||
'fs-layout', 'fs-darkmode', 'fs-palette', 'fs-wallpaper',
|
||
'fs-tint', 'fs-accent', 'fs-good', 'fs-warn', 'fs-danger',
|
||
'fs-card', 'fs-control', 'fs-bar', 'fs-line',
|
||
'fs-radius', 'fs-menu-autocollapse', 'fs-tint-strength', 'fs-density',
|
||
'fs-photo-dim', 'fs-pattern-size', 'fs-pattern-strength', 'fs-pattern-ink'
|
||
];
|
||
/* Tint density: the STRENGTH of the router-identity Tint (the hue washed onto --fs-bg), a per-browser
|
||
* axis paired with the Tint hue — the hue picks the colour, this picks how strong it reads.
|
||
* --fs-tint-strength is a multiplier on the tint chroma (03-palettes.css): 100% = the designed
|
||
* strength, up to 200%. 0 is not quite "no tint": the palette's canvas carries a slight cast of its
|
||
* own and the relative colour that applies the tint replaces chroma outright, so 0 leaves a neutral
|
||
* canvas at the same lightness rather than the untinted one (03-palettes.css measures it). Clearing
|
||
* the Tint hue is the real off. It only bites while a Tint hue is set (data-tint), and it is
|
||
* hidden and moot under the File wallpaper, where the tint resets to neutral (the photo covers the
|
||
* canvas). A normal per-browser axis: localStorage ?? router default ?? built-in; head.ut pre-paints
|
||
* it; stored explicitly (incl. the 100 default) so it can override a router default, like the hues.
|
||
*
|
||
* The axis and its default BOTH live up here, above _resolvedDefault()'s module-init call below: a
|
||
* propAxis instance is a `const`, so declaring it further down (where the slider's other siblings sit)
|
||
* puts it in the TDZ at init and the whole module throws — taking the chrome and the menu with it.
|
||
* That is not hypothetical; it was measured, and the empty sidebar is the only symptom. */
|
||
const FS_TSTR_DEFAULT = 100;
|
||
const TSTR = propAxis('fs-tint-strength', 'tint_strength', '--fs-tint-strength', 0, 200, FS_TSTR_DEFAULT, (v) => String(v / 100));
|
||
const currentTintStrength = TSTR.current, applyTintStrength = TSTR.apply, tintStrengthDefault = TSTR.def;
|
||
|
||
/* Photo dim: the scrim opacity over the FILE photo (0–100%). An ordinary per-browser axis, the same
|
||
* propAxis shape as the Tint's strength — the photo is shared, how strongly THIS browser dims it is
|
||
* not, and it reaches the router with the others through Save-as-default. It only bites while the
|
||
* wallpaper is 'file'. Distinct from Tint strength (fs-tint-strength), which colours the canvas.
|
||
*
|
||
* Declared up here with the other axis instances, above _resolvedDefault()'s module-init call: a
|
||
* propAxis is a `const`, so declaring it lower down leaves it in the TDZ at init and the whole
|
||
* module throws, taking the chrome with it. */
|
||
const FS_PDIM_DEFAULT = 74;
|
||
const PDIM = propAxis('fs-photo-dim', 'photo_dim', '--fs-photo-dim', 0, 100, FS_PDIM_DEFAULT, (v) => (v + '%'));
|
||
const currentPhotoDim = PDIM.current, applyPhotoDim = PDIM.apply, photoDimDefault = PDIM.def;
|
||
|
||
/* The PATTERN's two live knobs, and the third that is an enum. All three only bite while the
|
||
* wallpaper is 'pattern'; all three are ordinary per-browser axes that reach the router through
|
||
* Save-as-default with the rest — the FILE is shared, how this browser draws it is not.
|
||
*
|
||
* Size is the tile's edge in px. 440 is where the old cats doodle read as a drawing rather than as
|
||
* texture, and it is a sane middle for line art at any density; the range is wide because "how big
|
||
* is one repeat" is entirely a property of the artwork. Strength is the layer's opacity 0-100 —
|
||
* the doodles baked .20 into the file with an SVG `<g opacity>` and CSS could not reach it, which
|
||
* is exactly the knob this replaces.
|
||
*
|
||
* Declared up here with the other axis instances, above _resolvedDefault()'s module-init call: a
|
||
* propAxis is a `const`, so declaring it lower leaves it in the TDZ at init and the whole module
|
||
* throws, taking the chrome with it. */
|
||
const FS_PSIZE_DEFAULT = 440;
|
||
const PSIZE = propAxis('fs-pattern-size', 'pattern_size', '--fs-pattern-size', 40, 1600, FS_PSIZE_DEFAULT, (v) => (v + 'px'));
|
||
const currentPatternSize = PSIZE.current, applyPatternSize = PSIZE.apply, patternSizeDefault = PSIZE.def;
|
||
const FS_PSTR_DEFAULT = 20;
|
||
const PSTR = propAxis('fs-pattern-strength', 'pattern_strength', '--fs-pattern-strength', 0, 100, FS_PSTR_DEFAULT, (v) => String(v / 100));
|
||
const currentPatternStrength = PSTR.current, applyPatternStrength = PSTR.apply, patternStrengthDefault = PSTR.def;
|
||
/* Ink: 'theme' (the default — the file's alpha, the theme's colour) or 'original' (the file's own
|
||
* colours, no mask). Two-valued with the default as the bare :root, which is the enumAxis shape. */
|
||
const PINK = enumAxis('fs-pattern-ink', 'data-pattern-ink', 'original', 'theme');
|
||
const currentPatternInk = PINK.current, applyPatternInk = PINK.apply;
|
||
/* `reject: true` IS THE WHOLE POINT — without it a refused write arrives as SUCCESS.
|
||
*
|
||
* rpc.js only raises on the ubus status code when the declaration asks it to (`raise: options.reject`
|
||
* → `if (req.raise && msg.result[0] !== 0) L.raise(...)`); otherwise it hands the code back as the
|
||
* resolved VALUE. Only an object/method-level denial (JSON-RPC -32002) or an HTTP error rejects on
|
||
* its own. So a per-config ACL refusal — `uci` granted, `footstrap` not — resolved with `6`
|
||
* (UBUS_STATUS_PERMISSION_DENIED) and every `.then()` below ran as if the file had been written.
|
||
*
|
||
* Measured on the router with the theme's own ACL narrowed to a config name that does not exist,
|
||
* and the two wildcard-granting packages installed here moved aside so the denial could actually be
|
||
* reached: the declaration WITHOUT the flag resolved with value 6, the same declaration WITH it
|
||
* rejected `ubus code 6: Permission denied`. Before this flag, Appearance → Save as default greyed
|
||
* the button and printed "Saved as default" for a write that never happened, and the user could not
|
||
* even retry — the button was disabled. */
|
||
const _uciSet = rpc.declare({ object: 'uci', method: 'set', params: [ 'config', 'section', 'values' ], reject: true });
|
||
const _uciCommit = rpc.declare({ object: 'uci', method: 'commit', params: [ 'config' ], reject: true });
|
||
|
||
function snapshotAxes() {
|
||
return {
|
||
layout: currentLayout(),
|
||
darkmode: currentMode(),
|
||
palette: currentPalette(),
|
||
wallpaper: currentWallpaper(),
|
||
tint: String(currentTint()),
|
||
accent: String(currentAccent()),
|
||
good: String(currentGood()),
|
||
warn: String(currentWarn()),
|
||
danger: String(currentDanger()),
|
||
card: String(currentCard()),
|
||
control: String(currentControl()),
|
||
bar: String(currentBar()),
|
||
line: String(currentLine()),
|
||
rounding: String(currentRadius()),
|
||
autocollapse: currentAutoCollapse() ? 'on' : 'off',
|
||
tint_strength: String(currentTintStrength()),
|
||
density: currentDensity(),
|
||
photo_dim: String(currentPhotoDim()),
|
||
pattern_size: String(currentPatternSize()),
|
||
pattern_strength: String(currentPatternStrength()),
|
||
pattern_ink: currentPatternInk()
|
||
};
|
||
}
|
||
/* The RESOLVED router default (UCI value if set, else the built-in), in snapshotAxes() string form,
|
||
* so the Appearance tab can grey the Save button out when this browser already shows exactly it. Seeded
|
||
* from window.__fsSD at load and replaced with the just-saved snapshot after saveAsDefault(), so a
|
||
* save flips the match to true without a reload.
|
||
*
|
||
* Every field is the axis's OWN def() — this used to restate each one instead (the 1..360 clamp
|
||
* twice, 0..20 once, and a bare `sd('palette') || 'footstrap'` where current() whitelists), i.e. a
|
||
* second copy of a validation with no symptom when the two disagree: `matchesSavedDefault()` simply
|
||
* lies, and the Save button IS that answer. `layout` is the one exception and cannot be otherwise —
|
||
* currentLayout() reads the ATTRIBUTE, so this is the only place stating the layout router default.
|
||
* Its fallback is TOP and must stay the third copy of one answer: head.ut stamps `top` when uci
|
||
* says nothing and resetToBuiltin() applies `top`. It read 'sidebar' here after the default
|
||
* flipped, and the symptom is silent — on a fresh install matchesSavedDefault() is false before the
|
||
* user has touched anything, so Save-as-default shows dirty and resetToSaved() lands on the wrong
|
||
* layout. */
|
||
function _resolvedDefault() {
|
||
return {
|
||
layout: sd('layout') || 'top',
|
||
darkmode: modeDefault(),
|
||
palette: paletteDefault(),
|
||
wallpaper: wallpaperDefault(),
|
||
tint: String(TINT.def()),
|
||
accent: String(ACCENT.def()),
|
||
good: String(GOOD.def()),
|
||
warn: String(WARN.def()),
|
||
danger: String(DANGER.def()),
|
||
card: String(CARD.def()),
|
||
control: String(CONTROL.def()),
|
||
bar: String(BAR.def()),
|
||
line: String(LINE.def()),
|
||
rounding: String(radiusDefault()),
|
||
autocollapse: autoCollapseDefault() ? 'on' : 'off',
|
||
tint_strength: String(tintStrengthDefault()),
|
||
density: densityDefault(),
|
||
photo_dim: String(photoDimDefault()),
|
||
pattern_size: String(patternSizeDefault()),
|
||
pattern_strength: String(patternStrengthDefault()),
|
||
pattern_ink: PINK.def()
|
||
};
|
||
}
|
||
let _savedDefault = _resolvedDefault();
|
||
function matchesSavedDefault() {
|
||
const cur = snapshotAxes();
|
||
return Object.keys(cur).every((k) => cur[k] === _savedDefault[k]);
|
||
}
|
||
|
||
/* ---- no AXIS reaches /etc/config/footstrap except through Save-as-default ----------------------
|
||
* EVERY axis is per-browser and reaches the router only through this button. Two of them used to
|
||
* write through the moment they changed — `wallpaper` on every pick, `photo_dim` on every drag —
|
||
* on the argument that the File photo is router-side, so "which wallpaper shows it" and "how dim"
|
||
* belonged beside the image. The argument does not survive the consequence: choosing Cats in ONE
|
||
* browser silently re-pointed the router-wide default for every other device, and because the
|
||
* write also moved the Save baseline, the button did not even light up. A per-browser preference
|
||
* must never mutate shared state with no way to see that it did.
|
||
*
|
||
* The photo FILE stays router-side, because a file cannot live in localStorage — but only its
|
||
* bytes and its cache-bust token do. Whether a given browser paints it is `fs-wallpaper`, and how
|
||
* dim it paints it is `fs-photo-dim`: ordinary axes, saved with the rest or not at all. */
|
||
function saveAsDefault() {
|
||
const snap = snapshotAxes();
|
||
return _uciSet('footstrap', 'settings', snap)
|
||
.then(() => _uciCommit('footstrap'))
|
||
.then(() => { _savedDefault = snap; });
|
||
}
|
||
/* ---- the two resets, and they are NOT the same escape hatch -----------------------------------
|
||
*
|
||
* Both drop this browser's tweaks; they differ in WHAT is underneath, which is the whole point of
|
||
* the three-layer model (localStorage ?? router default ?? built-in):
|
||
*
|
||
* resetToSaved() clears the keys, so every axis falls back through the layers — to the ROUTER
|
||
* default where Save-as-default set one, to the built-in where it did not. The
|
||
* browser goes back to INHERITING: a later Save-as-default from another device
|
||
* will show up here too.
|
||
* resetToBuiltin() writes the theme's own defaults EXPLICITLY, which is the only way to say
|
||
* "the theme as it ships, not what this router was told to look like". Clearing
|
||
* the keys cannot express it — that is precisely the sentence that means
|
||
* "inherit the router default" (see the header).
|
||
*
|
||
* Both leave /etc/config/footstrap alone: neither is a way to un-save a router default, and an
|
||
* admin who wants that presses Save as default from the look they want.
|
||
*
|
||
* The caller reloads so head.ut re-applies everything in one clean pass — the appliers would each
|
||
* repaint correctly, but the CONTROLS on the page are built from the values they had at render
|
||
* time and would go on showing the old ones. */
|
||
function resetToSaved() {
|
||
AXIS_KEYS.forEach(lsDel);
|
||
}
|
||
|
||
/* The built-in defaults, written through the ordinary appliers so each one validates its own value
|
||
* and stamps :root the way it always does. Stated here rather than derived: a "default" is only a
|
||
* default because it is the value a bare :root paints, and the five that already have a named
|
||
* const (rounding, tint strength, photo dim, pattern size, pattern strength) use it, so the numbers
|
||
* cannot drift from the CSS. */
|
||
function resetToBuiltin() {
|
||
/* TOP, not sidebar: the bar is what a bare :root paints (head.ut stamps it when uci says
|
||
* nothing), so it is what "the theme as it ships" means. This said 'sidebar' after the default
|
||
* flipped and nothing caught it — the button quietly reset to a layout that is no longer the
|
||
* default, which is the one thing this button must not do. */
|
||
applyLayout('top');
|
||
applyMode('auto');
|
||
applyPalette('footstrap');
|
||
applyDensity('normal');
|
||
applyWallpaper('off');
|
||
applyAutoCollapse('off');
|
||
applyRadius(FS_RADIUS_DEFAULT);
|
||
applyTintStrength(FS_TSTR_DEFAULT);
|
||
applyPhotoDim(FS_PDIM_DEFAULT);
|
||
applyPatternSize(FS_PSIZE_DEFAULT);
|
||
applyPatternStrength(FS_PSTR_DEFAULT);
|
||
applyPatternInk('theme');
|
||
/* every colour and surface axis back to "the palette's own" */
|
||
[ applyTint, applyAccent, applyGood, applyWarn, applyDanger,
|
||
applyCard, applyControl, applyBar, applyLine ].forEach((fn) => fn(0));
|
||
}
|
||
|
||
/* ---- the PATTERN: an SVG the admin uploads, tiled and recoloured -------------------------------
|
||
*
|
||
* The theme used to ship two doodle patterns by downloading them from the project's GitHub on
|
||
* demand. A theme in a package feed has no business reaching a third-party host at run time, and
|
||
* "two drawings somebody else chose" was never the interesting half of the feature — so the bytes
|
||
* now come from the admin, and the theme's job is to make an arbitrary SVG look like it belongs.
|
||
*
|
||
* ROUTER-SIDE, like the login photo and for the same reason: a file cannot live in localStorage,
|
||
* and a pattern is something a router wears, not something one browser does. The path is a FIXED
|
||
* server-side constant matched exactly by the rpcd ACL, so nothing user-controlled reaches a path.
|
||
* It lives under /etc so a package upgrade cannot delete it (and keep.d carries it across a
|
||
* sysupgrade); the served name ENDS IN .svg because uhttpd types a file by extension, and an SVG
|
||
* served as application/octet-stream is one no browser will paint.
|
||
*
|
||
* HOW IT IS MADE TO FIT is 15-wallpaper.css's mask, not anything done to the bytes: the file
|
||
* supplies the alpha, the theme supplies the colour, so one upload reads correctly in light mode,
|
||
* in dark mode and under every palette. The two live knobs — tile size and strength — are ordinary
|
||
* per-browser axes below.
|
||
*
|
||
* WHAT IS REFUSED. An SVG is a document, not a picture: it can carry script, and while a masked or
|
||
* background image never executes it, the same file fetched directly from its URL would. Uploading
|
||
* one already needs an authenticated admin session with uci write rights, so this is defence in
|
||
* depth rather than the only line — but the check is cheap and the failure mode is somebody else's
|
||
* browser. Scripted or externally-referencing markup is rejected client-side, before anything is
|
||
* written. */
|
||
const PAT_PATH = '/etc/footstrap/pattern.svg'; /* cgi-upload target; the ACL grants exactly this */
|
||
const PAT_SERVE = '/luci-static/footstrap/pattern.svg'; /* the uhttpd symlink to PAT_PATH (uci-defaults) */
|
||
const PAT_MAX = 512 * 1024; /* a tile that has to reach a router's flash and then every page load */
|
||
/* WHAT MAKES AN UPLOADED SVG UNACCEPTABLE, decided on the PARSED DOCUMENT and not on its text.
|
||
*
|
||
* A regex over the source was the first attempt and it was wrong in the way that matters: `\son\w+=`
|
||
* (meant for `onload=`) also matches `only_selected="false"`, an ordinary Inkscape attribute, so one
|
||
* of this project's own sample drawings was refused by its own gate. Text matching is guessing at a
|
||
* grammar the browser already implements — and it guesses in both directions, since an entity or an
|
||
* odd bit of whitespace hides a real handler from the same regex.
|
||
*
|
||
* DOMParser is the parser the file will actually be read by, and parsing is inert: no script runs, no
|
||
* subresource is fetched, no handler is bound. So the questions become exact ones about nodes:
|
||
*
|
||
* - is it an SVG at all (a parsererror, or a root that is not <svg>, is not an image)
|
||
* - does it carry an element that EXECUTES or EMBEDS (script, foreignObject, iframe, …)
|
||
* - does it carry a real event-handler attribute — `^on[a-z]+$`, which `only_selected` is not
|
||
* - does any value start a `javascript:` url
|
||
* - does any href point OFF this router — an SVG that phones home when painted is the exact thing
|
||
* this feature stopped doing; `#fragment` and `data:` stay allowed, because that is how a tile
|
||
* refers to its own <defs> and how it embeds a bitmap
|
||
*
|
||
* A masked or backgrounded SVG never executes anything in any current browser. The check is for the
|
||
* OTHER way the file can be reached — its own URL, opened directly, same-origin with the session.
|
||
*
|
||
* `animate`/`set` are on the list for a second reason as well as the first: they can retarget an
|
||
* attribute at run time (the classic `<set attributename="href" to="javascript:…">`), and a tile
|
||
* that animates repaints a full-viewport layer behind every page for as long as LuCI is open. */
|
||
const PAT_BAD_TAGS = [ 'script', 'foreignobject', 'iframe', 'embed', 'object', 'audio', 'video', 'animate', 'set' ];
|
||
|
||
/* null if the parsed document is fine, otherwise the sentence to show. */
|
||
function _svgObjection(text) {
|
||
let doc;
|
||
try { doc = new DOMParser().parseFromString(text, 'image/svg+xml'); }
|
||
catch (e) { return _('That file is not an SVG image.', 'footstrap'); }
|
||
const root = doc && doc.documentElement;
|
||
if (!root || doc.querySelector('parsererror') || root.nodeName.toLowerCase() !== 'svg')
|
||
return _('That file is not an SVG image.', 'footstrap');
|
||
const refused = _('That SVG contains script or external references, which this theme will not install.', 'footstrap');
|
||
const els = [ root ].concat([ ...root.querySelectorAll('*') ]);
|
||
for (const el of els) {
|
||
if (PAT_BAD_TAGS.indexOf(el.nodeName.toLowerCase()) >= 0) return refused;
|
||
const attrs = el.attributes || [];
|
||
for (let i = 0; i < attrs.length; i++) {
|
||
const n = attrs[i].name.toLowerCase();
|
||
const v = String(attrs[i].value || '').trim();
|
||
/* a REAL handler is `on` + letters and nothing else; `only_selected` is not one */
|
||
if ((/^on[a-z]+$/).test(n)) return refused;
|
||
if ((/^javascript:/i).test(v)) return refused;
|
||
/* off-router reference. A leading `//` is protocol-relative and just as external. */
|
||
if ((/(?:^|:)href$/).test(n) && (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i).test(v)) return refused;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/* Read the picked file as text so it can be inspected before it is uploaded — and so the thing that
|
||
* reaches the router is exactly the bytes that were checked. */
|
||
function _readText(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const fr = new FileReader();
|
||
fr.onload = () => resolve(String(fr.result || ''));
|
||
fr.onerror = () => reject(new Error(_('That file could not be read.', 'footstrap')));
|
||
fr.readAsText(file);
|
||
});
|
||
}
|
||
|
||
/* the token the server last saved (window.__fsSD.pattern), validated to the same hex charset the
|
||
* head.ut sanitiser and the pre-paint keep their own copies of. '' = nothing uploaded. */
|
||
function currentPattern() {
|
||
const t = sd('pattern');
|
||
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
|
||
}
|
||
function patternUrl(tok) { return PAT_SERVE + '?v=' + tok; }
|
||
|
||
/* set / clear the tile URL live, without a reload. This only supplies the url(); whether it PAINTS
|
||
* is the Wallpaper axis (data-wallpaper="pattern"). */
|
||
function _applyPattern(tok) {
|
||
const root = document.documentElement;
|
||
if (tok) root.style.setProperty('--fs-pattern-url', 'url("' + patternUrl(tok) + '")');
|
||
else root.style.removeProperty('--fs-pattern-url');
|
||
setSD('pattern', tok || '');
|
||
}
|
||
|
||
/* Upload flow, the login photo's exactly: validate -> multipart POST to cgi-io's cgi-upload -> take
|
||
* the md5 `checksum` from the reply as the cache-bust token -> save that token in uci -> apply live.
|
||
* No canvas step: re-encoding is what strips a photo's EXIF, and an SVG re-drawn to a canvas would
|
||
* come back a raster and lose the one property that makes it a tile. The text check above is what
|
||
* stands in for it. */
|
||
function uploadPattern(file) {
|
||
if (!file) return Promise.reject(new Error(_('Please choose an SVG file.', 'footstrap')));
|
||
const isSvg = (/(^image\/svg\+xml$)/i).test(file.type || '') || (/\.svg$/i).test(file.name || '');
|
||
if (!isSvg) return Promise.reject(new Error(_('Please choose an SVG file.', 'footstrap')));
|
||
if (file.size > PAT_MAX) return Promise.reject(new Error(_('That file is too large.', 'footstrap')));
|
||
return _readText(file).then((text) => {
|
||
const objection = _svgObjection(text);
|
||
if (objection) return Promise.reject(new Error(objection));
|
||
const fd = new FormData();
|
||
fd.append('sessionid', rpc.getSessionID());
|
||
fd.append('filename', PAT_PATH);
|
||
fd.append('filedata', new Blob([ text ], { type: 'image/svg+xml' }), 'pattern.svg');
|
||
return fetch(L.env.cgi_base + '/cgi-upload', { method: 'POST', body: fd, credentials: 'same-origin' })
|
||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))));
|
||
}).then((reply) => {
|
||
if (!reply || reply.failure)
|
||
return Promise.reject(new Error((reply && reply.failure && reply.failure[1]) || _('Upload failed.', 'footstrap')));
|
||
const tok = String(reply.checksum || '').toLowerCase();
|
||
if (!BG_TOKEN_RE.test(tok))
|
||
return Promise.reject(new Error(_('Upload failed.', 'footstrap')));
|
||
/* cgi-upload writes 0600 and uhttpd refuses to SERVE a file that is not world-readable
|
||
* (measured: 0600 -> 403, 0644 -> 200) — and _chmodServeable checks the COMMAND's exit
|
||
* status, not just the ubus call's. */
|
||
return _chmodServeable(PAT_PATH)
|
||
/* uci gets the TOKEN and nothing else: putting a file on the router is not the same act
|
||
* as making every other device paint it, which is the wallpaper axis and Save-as-default. */
|
||
.then(() => _uciSet('footstrap', 'settings', { pattern: tok }))
|
||
.then(() => _uciCommit('footstrap'))
|
||
.catch((e) => _rollbackUpload(PAT_PATH, e))
|
||
.then(() => {
|
||
/* switch THIS browser onto it — the ordinary axis path, localStorage only */
|
||
applyWallpaper('pattern');
|
||
_applyPattern(tok);
|
||
return tok;
|
||
});
|
||
});
|
||
}
|
||
|
||
/* Remove: delete the file, blank the token (uci `set` to '', not delete — the scoped ACL grants
|
||
* set/commit only), clear the tile live. */
|
||
function removePattern() {
|
||
return _removeServed(PAT_PATH)
|
||
.then(() => _uciSet('footstrap', 'settings', { pattern: '' }))
|
||
.then(() => _uciCommit('footstrap'))
|
||
.then(() => { _applyPattern(''); });
|
||
}
|
||
|
||
/* ---- Login/page background upload: ROUTER-SIDE, and deliberately NOT an axis --------------------
|
||
* The other axes are per-browser (localStorage) with a router default; this one has no browser layer
|
||
* at all. An admin uploads an image once, it becomes the router-wide background for EVERY device and
|
||
* shows pre-login, and there is nothing to override locally — so it is absent from AXIS_KEYS,
|
||
* snapshotAxes() and matchesSavedDefault() (it must not move the Save button), and it needs no
|
||
* enum/hue factory (so tools/axes.mjs never sees it).
|
||
*
|
||
* The image is a SERVED FILE (uhttpd has no gzip — inlining a photo in every page's <head> is out);
|
||
* only its cache-bust token lives in uci -> window.__fsSD -> the url() head.ut stamps. The file path
|
||
* is a FIXED server-side constant, matched exactly by the rpcd ACL, so nothing user-controlled ever
|
||
* reaches a path — no traversal surface. */
|
||
const BG_PATH = '/etc/footstrap/login-bg'; /* cgi-upload target; the ACL grants exactly this */
|
||
const BG_SERVE = '/luci-static/footstrap/bg'; /* the uhttpd symlink to BG_PATH (uci-defaults) */
|
||
const BG_MAX_SIDE = 1920; /* cap the longest side — a router serves this off flash with no gzip, and 1080p covers the screens LuCI is actually admin'd from; still crisp full-screen, far fewer flash/wire bytes */
|
||
const BG_QUALITY = 0.9;
|
||
const BG_SRC_MAX = 25 * 1024 * 1024; /* refuse a source this big before decoding (decode-bomb guard) */
|
||
/* NO `reject: true` here, unlike every other declare in this file, and that is the whole point: with
|
||
* it the caller gets an Error whose only account of WHY is a sentence with the ubus code inside it,
|
||
* so "the file was already gone" and "the router refused to delete it" arrive indistinguishable —
|
||
* and both callers below used to swallow the rejection whole and report success. Without it the
|
||
* promise resolves with the ubus status as a NUMBER (rpc.js hands back `msg.result[0]`), which is a
|
||
* signal this code can actually branch on. */
|
||
const _fileRemoveStatus = rpc.declare({ object: 'file', method: 'remove', params: [ 'path' ] });
|
||
|
||
/* Delete, treating "not found" as done. Anything else is a real refusal — a read-only or full
|
||
* overlay, an immutable flag, a path replaced by a non-empty directory — and it must NOT be reported
|
||
* as a removal: the file stays on flash and stays fetchable WITHOUT A SESSION through the /www
|
||
* symlink, which is precisely what an admin removing a background for privacy reasons believes they
|
||
* have just stopped. */
|
||
const UBUS_NOT_FOUND = 4;
|
||
function _removeServed(path) {
|
||
return _fileRemoveStatus(path).then((res) => {
|
||
const code = (typeof res === 'number') ? res : parseInt(res, 10);
|
||
if (code === 0 || code === UBUS_NOT_FOUND || isNaN(code)) return;
|
||
return Promise.reject(new Error(
|
||
_('The router refused to delete the file (ubus status %d).', 'footstrap').format(code)));
|
||
});
|
||
}
|
||
/* cgi-upload writes the file mode 0600, and uhttpd refuses to SERVE a file that is not
|
||
* world-readable (measured: 0600 -> 403, 0644 -> 200), so make it 0644 before it can be fetched. The
|
||
* rpcd ACL grants exec on exactly two fixed commands — `/bin/chmod 644 /etc/footstrap/login-bg` and
|
||
* `/bin/chmod 644 /etc/footstrap/pattern.svg`, the two files this module uploads — with no argument
|
||
* the caller controls. */
|
||
const _fileExec = rpc.declare({ object: 'file', method: 'exec', params: [ 'command', 'params' ], reject: true });
|
||
/* …and the ubus status is only half of it: `file.exec` reports the COMMAND's exit status inside the
|
||
* payload, so a chmod that ran and failed still comes back as a successful call. Unchecked, the
|
||
* chain below went on to commit `wallpaper=file` router-wide for a file uhttpd will 403 (cgi-upload
|
||
* writes it 0600) — the upload reports success and every device, including the pre-login page, gets
|
||
* a scrim over nothing. */
|
||
function _chmodServeable(path) {
|
||
return _fileExec('/bin/chmod', [ '644', path ]).then((res) => {
|
||
if (res && res.code)
|
||
throw new Error(_('Upload failed.', 'footstrap') + ' (chmod ' + res.code + ')');
|
||
return res;
|
||
});
|
||
}
|
||
/* the cache-bust token charset — an md5/sha hex string. ONE copy here (currentLoginBg validates the
|
||
* stored token, uploadLoginBg validates the fresh cgi-upload checksum); head.ut's ucode sanitiser and
|
||
* the pre-paint inline script keep their own identical copies, unavoidably (they run before this
|
||
* module and cannot require it) — see the axes contract in head.ut. */
|
||
const BG_TOKEN_RE = /^[a-f0-9]{6,64}$/;
|
||
|
||
/* the token the server last saved (window.__fsSD.login_bg), validated to the same hex charset the
|
||
* head.ut sanitiser and pre-paint use — so the Appearance tab shows the current background and builds a
|
||
* cache-busted preview src. '' = none. */
|
||
function currentLoginBg() {
|
||
const t = sd('login_bg');
|
||
return (typeof t === 'string' && BG_TOKEN_RE.test(t)) ? t : '';
|
||
}
|
||
function loginBgUrl(tok) { return BG_SERVE + '?v=' + tok; }
|
||
|
||
/* set / clear the photo URL live, without a reload. This only supplies the url() — whether it PAINTS
|
||
* is the Wallpaper axis (data-wallpaper="file", applyWallpaper above), so an upload while the browser
|
||
* is on file shows at once, and removing the image leaves the file layer with `none`. */
|
||
function _applyLoginBg(tok) {
|
||
const root = document.documentElement;
|
||
if (tok) root.style.setProperty('--fs-login-bg-url', "url('" + loginBgUrl(tok) + "')");
|
||
else root.style.removeProperty('--fs-login-bg-url');
|
||
setSD('login_bg', tok || '');
|
||
}
|
||
|
||
/* Re-encode the picked image to a bounded JPEG on a canvas. This is a SECURITY step as much as a
|
||
* size one: the canvas keeps only the decoded pixels, so EXIF and any bytes appended past the image
|
||
* are dropped — the uploaded blob is exactly what the browser drew and nothing else.
|
||
*
|
||
* THE WHOLE BODY IS GUARDED, because a throw inside an event handler does not reject the promise it
|
||
* sits in — it escapes as an uncaught error and leaves the promise pending FOREVER. Two real ways
|
||
* out of `onload`: `getContext('2d')` answers null when the canvas cannot be backed (out of memory
|
||
* on a low-RAM box is the case this decodes a 25 MB source on), and drawImage/toBlob can throw on
|
||
* their own. The caller is the Appearance tab's file picker, which disables "Choose image" and relabels
|
||
* it "Uploading…" before the call and restores both in a `.finally()` — so a pending promise means
|
||
* that button stays disabled and lying until the form is rebuilt, which mount() does only when the
|
||
* stock view re-renders: the next arrival at System -> System, not this one. */
|
||
function _downscale(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const url = URL.createObjectURL(file);
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
URL.revokeObjectURL(url);
|
||
try {
|
||
const scale = Math.min(1, BG_MAX_SIDE / Math.max(img.width, img.height));
|
||
const w = Math.max(1, Math.round(img.width * scale));
|
||
const h = Math.max(1, Math.round(img.height * scale));
|
||
const cv = document.createElement('canvas');
|
||
cv.width = w; cv.height = h;
|
||
const ctx = cv.getContext('2d');
|
||
if (!ctx) throw new Error('no 2d context');
|
||
ctx.drawImage(img, 0, 0, w, h);
|
||
cv.toBlob((blob) => blob ? resolve(blob) : reject(new Error(_('Could not process the image.', 'footstrap'))),
|
||
'image/jpeg', BG_QUALITY);
|
||
} catch (e) { reject(new Error(_('Could not process the image.', 'footstrap'))); }
|
||
};
|
||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error(_('That file is not a readable image.', 'footstrap'))); };
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
/* An upload that has landed but could not be RECORDED must not stay on the router. The two paths
|
||
* below write the file first (cgi-upload) and the token second (uci), and the second half can fail
|
||
* on its own: no `settings` section yet, a narrowed uci ACL, ubus busy. The page then showed the
|
||
* rpc error — honest as far as it went — while the image sat in /etc/footstrap at mode 0644 and was
|
||
* served to ANYONE at /luci-static/footstrap/bg, because the /www symlink does not depend on the
|
||
* token. Worse, Remove is hidden exactly when the token is empty, so the page offered no way to
|
||
* delete what it had just published. Roll the file back instead, and report the failure that
|
||
* started it — a rollback that itself fails is appended, because at that point the admin has to
|
||
* know the file is there. */
|
||
function _rollbackUpload(path, cause) {
|
||
return _removeServed(path).then(
|
||
() => Promise.reject(cause),
|
||
() => Promise.reject(new Error(String((cause && cause.message) || cause) + ' — '
|
||
+ _('the uploaded file could not be removed either; it is still on the router.', 'footstrap')))
|
||
);
|
||
}
|
||
|
||
/* Upload flow: validate -> canvas re-encode -> multipart POST to cgi-io's cgi-upload (the same
|
||
* endpoint L.ui.uploadFile uses; session carried in the `sessionid` FIELD, path in `filename`, bytes
|
||
* in `filedata`) -> take the md5 `checksum` from the JSON reply as the cache-bust token -> save it in
|
||
* uci -> apply live. cgi-upload authorises the write against the ACL's `file` grant for BG_PATH. */
|
||
function uploadLoginBg(file) {
|
||
if (!file || !(/^image\//).test(file.type || ''))
|
||
return Promise.reject(new Error(_('Please choose an image file.', 'footstrap')));
|
||
if (file.size > BG_SRC_MAX)
|
||
return Promise.reject(new Error(_('That image is too large.', 'footstrap')));
|
||
return _downscale(file).then((blob) => {
|
||
const fd = new FormData();
|
||
fd.append('sessionid', rpc.getSessionID());
|
||
fd.append('filename', BG_PATH);
|
||
fd.append('filedata', blob, 'login-bg');
|
||
return fetch(L.env.cgi_base + '/cgi-upload', { method: 'POST', body: fd, credentials: 'same-origin' })
|
||
.then((r) => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)));
|
||
}).then((reply) => {
|
||
/* cgi-upload answers { name, size, checksum, sha256sum } or { failure: [code, msg] } */
|
||
if (!reply || reply.failure)
|
||
return Promise.reject(new Error((reply && reply.failure && reply.failure[1]) || _('Upload failed.', 'footstrap')));
|
||
const tok = String(reply.checksum || '').toLowerCase();
|
||
if (!BG_TOKEN_RE.test(tok))
|
||
return Promise.reject(new Error(_('Upload failed.', 'footstrap')));
|
||
/* make the just-written 0600 file world-readable, or uhttpd 403s it (see _fileExec) */
|
||
return _chmodServeable(BG_PATH)
|
||
/* uci gets the TOKEN and nothing else. Uploading a photo is not the same act as making it
|
||
* the router-wide background: it puts a file on the router, and which browsers paint it is
|
||
* the wallpaper axis, saved with the rest through Save-as-default. Writing `wallpaper:file`
|
||
* here would re-point every other device's default from one admin's upload, silently. */
|
||
.then(() => _uciSet('footstrap', 'settings', { login_bg: tok }))
|
||
.then(() => _uciCommit('footstrap'))
|
||
.catch((e) => _rollbackUpload(BG_PATH, e))
|
||
.then(() => {
|
||
/* switch THIS browser to the photo — the ordinary axis path, localStorage only */
|
||
applyWallpaper('file');
|
||
_applyLoginBg(tok);
|
||
return tok;
|
||
});
|
||
});
|
||
}
|
||
|
||
/* Remove: delete the file, blank the token (uci `set` to '', not delete — the scoped ACL grants
|
||
* set/commit only), clear the background live. */
|
||
function removeLoginBg() {
|
||
return _removeServed(BG_PATH)
|
||
.then(() => _uciSet('footstrap', 'settings', { login_bg: '' }))
|
||
.then(() => _uciCommit('footstrap'))
|
||
.then(() => { _applyLoginBg(''); });
|
||
}
|
||
|
||
|
||
return baseclass.extend({
|
||
/* the storage helpers */
|
||
lsGet, lsSet, lsDel, lsGetArr, storageBroken,
|
||
|
||
currentMode, applyMode, guardDarkStamp,
|
||
currentPalette, applyPalette,
|
||
currentWallpaper, applyWallpaper,
|
||
currentDensity, applyDensity,
|
||
currentRadius, applyRadius,
|
||
currentTint, applyTint,
|
||
currentAccent, applyAccent,
|
||
currentGood, applyGood,
|
||
currentCard, applyCard,
|
||
currentControl, applyControl,
|
||
currentBar, applyBar,
|
||
currentLine, applyLine,
|
||
currentWarn, applyWarn,
|
||
currentDanger, applyDanger,
|
||
currentLayout, isTopLayout, applyLayout,
|
||
currentAutoCollapse, applyAutoCollapse,
|
||
currentRail, applyRail,
|
||
|
||
currentLoginBg, loginBgUrl, uploadLoginBg, removeLoginBg,
|
||
currentPattern, patternUrl, uploadPattern, removePattern,
|
||
currentPatternSize, applyPatternSize,
|
||
currentPatternStrength, applyPatternStrength,
|
||
currentPatternInk, applyPatternInk,
|
||
currentTintStrength, applyTintStrength,
|
||
currentPhotoDim, applyPhotoDim,
|
||
|
||
saveAsDefault, resetToSaved, resetToBuiltin, matchesSavedDefault
|
||
});
|