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