From 8a9ae28b6fc7b08cbf0bd88d71ce369064bc5eb4 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 26 May 2026 23:49:09 +0100 Subject: [PATCH] feat(webui): developer mode + Android-style 10-click easter egg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What this delivers (Stage 1+2 of the dev-mode feature): 1. New `**DEV**` marker for config fields. Mirrors the existing `**ADVANCED**` pattern: stays in the description string, frontend strips it for display, presence flips a 'hide unless dev mode is on' behaviour. Implemented in ConfigUtils.cleanDescription / isDevField / isDevModeOn and in ConfigShared._filterDevKeys, which the two generateFieldsForCategory* helpers now call before rendering. 2. New CFG_DEV_MODE field in configs/general/general_install. Visible under Advanced; defaults to false. The canonical place to toggle dev mode (the WebUI easter egg writes to it, the auto-detector writes to it, and users can flip it directly here too). 3. Marked CFG_INSTALL_MODE and CFG_RELEASE_CHANNEL with `**DEV**`. Normal users no longer see either field β€” they install Release- Stable and that's the whole story. Devs see both with the user-facing labels you asked for: CFG_INSTALL_MODE Release - Stable | Git clone | Local folder CFG_RELEASE_CHANNEL Release - Stable | Release - Bleeding Edge (CFG_INSTALL_MODE label for the release option also renamed to match.) 4. 10-click LibrePortal-logo easter egg in topbar.js: - Counter on any .libreportal-logo click; idle-reset after 3 s - Toast countdown from click 6 ('4 clicks away from being a developer…') - At 10: toggles CFG_DEV_MODE via the standard config_update task (same path the Config form uses); shows 'πŸ› οΈ Developer mode unlocked. Reload to see the extra options.' - Re-using the same logo when dev mode is on toggles it back off ('… away from disabling developer mode') β€” symmetric, no separate UI 5. Auto-detect: on every WebUI load, if CFG_INSTALL_MODE is git or local AND CFG_DEV_MODE is off, auto-flip to on with a one-time toast 'Developer mode auto-enabled β€” you're on a git install. Click the LibrePortal logo 10Γ— to disable.' Stops dev-install users getting locked out of the very options they need to manage their install. Idempotent β€” runs once per page load; no-op if already on or on release. Disable surfaces: (a) CFG_DEV_MODE in Advanced on the Config form is the canonical toggle; (b) 10 more logo clicks. A 3rd surface (a System page banner) is deferred β€” those two cover the practical cases. Signed-off-by: librelad --- configs/general/general_install | 5 +- .../js/components/config/config-shared.js | 26 ++++- .../js/components/config/config-utils.js | 13 +++ .../frontend/js/components/topbar.js | 102 ++++++++++++++++++ 4 files changed, 139 insertions(+), 7 deletions(-) diff --git a/configs/general/general_install b/configs/general/general_install index 33795ed..987b49e 100755 --- a/configs/general/general_install +++ b/configs/general/general_install @@ -1,9 +1,10 @@ # ================================================================================ # Installation Setup - Local or Git Repository configuration and version control # ================================================================================ -CFG_INSTALL_MODE=release # Installation Mode - How LibrePortal is fetched + updated [release:Release tarball (recommended)|git:Git clone (dev)|local:Local folder (dev)] +CFG_INSTALL_MODE=release # Installation Mode - How LibrePortal is fetched + updated. Hidden by default β€” only shown when Developer Mode is on (click the LibrePortal logo 10 times) or when the install is already on git/local (auto-enables Developer Mode). **DEV** [release:Release - Stable|git:Git clone|local:Local folder] CFG_RELEASE_BASE_URL=https://get.libreportal.org # Release Host - Base URL serving the release channels (override for self-hosting) **ADVANCED** -CFG_RELEASE_CHANNEL=stable # Release Channel - Which channel to install/update from [stable:Stable|edge:Edge] +CFG_RELEASE_CHANNEL=stable # Release Channel - Pick the release channel for the tarball installer. Stable is the recommended default; Edge ships from main and may contain in-flight changes. **DEV** [stable:Release - Stable|edge:Release - Bleeding Edge] +CFG_DEV_MODE=false # Developer Mode - Reveal advanced developer / dev-install options across the WebUI. Auto-enables when the install is already on git/local. Easter egg: click the LibrePortal logo 10 times to toggle. **ADVANCED** [true:On|false:Off] CFG_GIT_URL=changeme # Git Repository URL - Git repository URL for LibrePortal configuration CFG_GIT_USER=changeme # Git Username - Git username for repository authentication CFG_GIT_KEY=changeme # Git Access Key - SSH key or API key for Git repository access diff --git a/containers/libreportal/frontend/js/components/config/config-shared.js b/containers/libreportal/frontend/js/components/config/config-shared.js index f91ed95..c25a5a1 100755 --- a/containers/libreportal/frontend/js/components/config/config-shared.js +++ b/containers/libreportal/frontend/js/components/config/config-shared.js @@ -1118,10 +1118,25 @@ class ConfigShared { }); } + // Filter helper β€” drops fields tagged with the **DEV** marker (in their + // description) when CFG_DEV_MODE is off. Keeps everything else verbatim. + // The marker convention (and the dev-mode flag) is shared with + // ConfigUtils β€” see cleanDescription / isDevField / isDevModeOn. + static _filterDevKeys(keys, config) { + const cfg = (window.systemConfigs || {}); + const devOn = (cfg.CFG_DEV_MODE === 'true' || cfg.CFG_DEV_MODE === true); + if (devOn) return keys; + return keys.filter(key => { + const desc = (config[key] && config[key].description) || ''; + return !desc.includes('**DEV**'); + }); + } + // Generate fields for category with 3-per-line layout and smart field detection static generateFieldsForCategory(keys, category, config, generateFieldCallback = null) { + keys = this._filterDevKeys(keys, config); let formHTML = '
'; - + keys.forEach((key, index) => { const configItem = config[key] || {}; const value = configItem.value || ''; @@ -1129,12 +1144,12 @@ class ConfigShared { const description = configItem.description || ''; const options = configItem.options || ''; const fieldId = `config-${key}`; - + // Add line break every 3 items if (index > 0 && index % 3 === 0) { formHTML += `
`; } - + // Use smart field creation if no callback provided, otherwise use callback if (generateFieldCallback) { formHTML += generateFieldCallback(fieldId, key, value, title, description, options, config); @@ -1147,15 +1162,16 @@ class ConfigShared { }); } }); - + formHTML += `
`; return formHTML; } // Generate fields for category WITHOUT the leading divider (for master toggle sections) static generateFieldsForCategoryNoDivider(keys, category, config, generateFieldCallback = null) { + keys = this._filterDevKeys(keys, config); let formHTML = ''; - + keys.forEach((key, index) => { const configItem = config[key] || {}; const value = configItem.value || ''; diff --git a/containers/libreportal/frontend/js/components/config/config-utils.js b/containers/libreportal/frontend/js/components/config/config-utils.js index f897b0d..9151abc 100755 --- a/containers/libreportal/frontend/js/components/config/config-utils.js +++ b/containers/libreportal/frontend/js/components/config/config-utils.js @@ -12,10 +12,23 @@ class ConfigUtils { return description .replace(/\*\*ADVANCED\*\*/g, '') .replace(/\*\*UNUSED\*\*/g, '') + .replace(/\*\*DEV\*\*/g, '') .replace(/^\s+|\s+$/g, '') // Trim whitespace .replace(/\s{2,}/g, ' '); // Replace multiple spaces with single space } + // Per-field test for the **DEV** marker. Dev fields are hidden in the form + // unless CFG_DEV_MODE is on (unlocked via the 10-click LibrePortal-logo + // easter egg, or auto-enabled when CFG_INSTALL_MODE is git/local). + isDevField(description) { + return typeof description === 'string' && description.includes('**DEV**'); + } + + isDevModeOn() { + const v = (window.systemConfigs && window.systemConfigs.CFG_DEV_MODE) || 'false'; + return v === 'true' || v === true; + } + filterSubcategoriesByType(configData, category) { // Filter subcategories by category and separate into regular, advanced, and unused var regularSubcategories = []; diff --git a/containers/libreportal/frontend/js/components/topbar.js b/containers/libreportal/frontend/js/components/topbar.js index ee7d16c..c6b4967 100755 --- a/containers/libreportal/frontend/js/components/topbar.js +++ b/containers/libreportal/frontend/js/components/topbar.js @@ -70,10 +70,112 @@ class TopbarComponent { this.setupLogout(); this.setupConfigUpdateLockout(); this.setupSetupGate(); + this.setupDevModeEasterEgg(); + this.autoEnableDevModeIfNeeded(); // Mount the "out of date" badge now that .topbar-controls exists. window.updateNotifier?.onTopbarReady(); } + // 10-click LibrePortal-logo easter egg β†’ toggles CFG_DEV_MODE. Inspired by + // Android's "tap Build Number 7 times to become a developer". Same idea: + // dev-mode shows hidden CFG_* fields marked **DEV** (Installation Mode, + // Release Channel, etc.) that normal users never need to see. Disabling + // also via this path β€” 10 more clicks turns it back off. + setupDevModeEasterEgg() { + const TARGET_CLICKS = 10; // matches Android's pattern (well, 7 there) + const TOAST_FROM = 6; // start showing the countdown at click 6 + const RESET_AFTER_MS = 3000; // idle reset β€” don't accumulate across sessions + + let count = 0; + let resetTimer = null; + + document.addEventListener('click', (e) => { + const logo = e.target.closest('.libreportal-logo'); + if (!logo) return; + count++; + + if (resetTimer) clearTimeout(resetTimer); + resetTimer = setTimeout(() => { count = 0; }, RESET_AFTER_MS); + + const remaining = TARGET_CLICKS - count; + const devOn = (window.systemConfigs?.CFG_DEV_MODE === 'true'); + const verb = devOn ? 'disabling' : 'being'; + const noun = devOn ? 'developer mode' : 'a developer'; + + if (remaining > 0 && count >= TOAST_FROM) { + this._devToast(`You are ${remaining} click${remaining === 1 ? '' : 's'} away from ${verb} ${noun}.`, 'info'); + } else if (remaining === 0) { + count = 0; + clearTimeout(resetTimer); + this._setDevMode(!devOn); + } + }); + } + + // On WebUI load, if CFG_INSTALL_MODE is git/local but CFG_DEV_MODE is off, + // auto-enable it. Otherwise dev-install users get locked out of the very + // fields they need to manage their install. Idempotent β€” runs once per + // load; the no-op case (already on, or on release) is silent. + async autoEnableDevModeIfNeeded() { + try { + const r = await fetch(`/data/config/generated/configs.json?t=${Date.now()}`); + if (!r.ok) return; + const data = await r.json(); + const installMode = data?.config?.CFG_INSTALL_MODE?.value; + const devMode = data?.config?.CFG_DEV_MODE?.value; + const onDevInstall = (installMode === 'git' || installMode === 'local'); + const devModeOff = (devMode !== 'true' && devMode !== true); + if (onDevInstall && devModeOff) { + this._devToast(`Developer mode auto-enabled β€” you're on a ${installMode} install. Click the LibrePortal logo 10Γ— to disable.`, 'success'); + await this._setDevMode(true, /*silent*/ true); + } + } catch { /* missing configs.json, network blip β€” leave it; easter egg still works */ } + } + + // Toast helper β€” uses the project's notification system if loaded, else + // a quiet console echo so dev-mode UX never blocks page render. + _devToast(message, kind = 'info') { + if (window.notificationSystem?.show) { + window.notificationSystem.show(message, kind); + } else if (typeof window.showNotification === 'function') { + window.showNotification(message, kind); + } else { + console.log(`[dev-mode] ${message}`); + } + } + + // Flip CFG_DEV_MODE via the standard config-update task (same path the + // Config form uses). silent=true suppresses the success toast for the + // auto-detect path β€” the auto-detect path emits its own message first. + async _setDevMode(enabled, silent = false) { + const value = enabled ? 'true' : 'false'; + try { + if (!window.tasksManager?.router?.routeAction) { + this._devToast('Task system not ready β€” cannot toggle Developer Mode right now.', 'error'); + return; + } + const encoded = `CFG_DEV_MODE=${value}`; + await window.tasksManager.router.routeAction('config_update', { + changes: `'${encoded.replace(/'/g, "'\\''")}'` + }); + // Optimistically update the in-process cache so other components + // (e.g. config-shared.js's _filterDevKeys) see the new value without + // a full page refresh. + if (!window.systemConfigs) window.systemConfigs = {}; + window.systemConfigs.CFG_DEV_MODE = value; + if (!silent) { + this._devToast( + enabled + ? 'πŸ› οΈ Developer mode unlocked. Reload to see the extra options.' + : 'Developer mode disabled.', + 'success' + ); + } + } catch (err) { + this._devToast(`Failed to toggle Developer Mode: ${err.message || err}`, 'error'); + } + } + // Disable nav items entirely until the Setup Wizard has been completed. // The wizard itself runs as a full-screen overlay that blocks interaction; // this is a belt-and-braces guard for the brief window before the wizard