feat(webui): developer mode + Android-style 10-click easter egg

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 <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-26 23:49:09 +01:00
parent 48085e1d4d
commit 8a9ae28b6f
4 changed files with 139 additions and 7 deletions

View File

@ -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

View File

@ -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 = '<div class="domains-divider"></div><div class="config-fields">';
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 += `</div><div class="config-fields">`;
}
// 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 += `</div>`;
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 || '';

View File

@ -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 = [];

View File

@ -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