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