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:
parent
48085e1d4d
commit
8a9ae28b6f
@ -1,9 +1,10 @@
|
|||||||
# ================================================================================
|
# ================================================================================
|
||||||
# Installation Setup - Local or Git Repository configuration and version control
|
# 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_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_URL=changeme # Git Repository URL - Git repository URL for LibrePortal configuration
|
||||||
CFG_GIT_USER=changeme # Git Username - Git username for repository authentication
|
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
|
CFG_GIT_KEY=changeme # Git Access Key - SSH key or API key for Git repository access
|
||||||
|
|||||||
@ -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
|
// Generate fields for category with 3-per-line layout and smart field detection
|
||||||
static generateFieldsForCategory(keys, category, config, generateFieldCallback = null) {
|
static generateFieldsForCategory(keys, category, config, generateFieldCallback = null) {
|
||||||
|
keys = this._filterDevKeys(keys, config);
|
||||||
let formHTML = '<div class="domains-divider"></div><div class="config-fields">';
|
let formHTML = '<div class="domains-divider"></div><div class="config-fields">';
|
||||||
|
|
||||||
keys.forEach((key, index) => {
|
keys.forEach((key, index) => {
|
||||||
const configItem = config[key] || {};
|
const configItem = config[key] || {};
|
||||||
const value = configItem.value || '';
|
const value = configItem.value || '';
|
||||||
@ -1129,12 +1144,12 @@ class ConfigShared {
|
|||||||
const description = configItem.description || '';
|
const description = configItem.description || '';
|
||||||
const options = configItem.options || '';
|
const options = configItem.options || '';
|
||||||
const fieldId = `config-${key}`;
|
const fieldId = `config-${key}`;
|
||||||
|
|
||||||
// Add line break every 3 items
|
// Add line break every 3 items
|
||||||
if (index > 0 && index % 3 === 0) {
|
if (index > 0 && index % 3 === 0) {
|
||||||
formHTML += `</div><div class="config-fields">`;
|
formHTML += `</div><div class="config-fields">`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use smart field creation if no callback provided, otherwise use callback
|
// Use smart field creation if no callback provided, otherwise use callback
|
||||||
if (generateFieldCallback) {
|
if (generateFieldCallback) {
|
||||||
formHTML += generateFieldCallback(fieldId, key, value, title, description, options, config);
|
formHTML += generateFieldCallback(fieldId, key, value, title, description, options, config);
|
||||||
@ -1147,15 +1162,16 @@ class ConfigShared {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
formHTML += `</div>`;
|
formHTML += `</div>`;
|
||||||
return formHTML;
|
return formHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate fields for category WITHOUT the leading divider (for master toggle sections)
|
// Generate fields for category WITHOUT the leading divider (for master toggle sections)
|
||||||
static generateFieldsForCategoryNoDivider(keys, category, config, generateFieldCallback = null) {
|
static generateFieldsForCategoryNoDivider(keys, category, config, generateFieldCallback = null) {
|
||||||
|
keys = this._filterDevKeys(keys, config);
|
||||||
let formHTML = '';
|
let formHTML = '';
|
||||||
|
|
||||||
keys.forEach((key, index) => {
|
keys.forEach((key, index) => {
|
||||||
const configItem = config[key] || {};
|
const configItem = config[key] || {};
|
||||||
const value = configItem.value || '';
|
const value = configItem.value || '';
|
||||||
|
|||||||
@ -12,10 +12,23 @@ class ConfigUtils {
|
|||||||
return description
|
return description
|
||||||
.replace(/\*\*ADVANCED\*\*/g, '')
|
.replace(/\*\*ADVANCED\*\*/g, '')
|
||||||
.replace(/\*\*UNUSED\*\*/g, '')
|
.replace(/\*\*UNUSED\*\*/g, '')
|
||||||
|
.replace(/\*\*DEV\*\*/g, '')
|
||||||
.replace(/^\s+|\s+$/g, '') // Trim whitespace
|
.replace(/^\s+|\s+$/g, '') // Trim whitespace
|
||||||
.replace(/\s{2,}/g, ' '); // Replace multiple spaces with single space
|
.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) {
|
filterSubcategoriesByType(configData, category) {
|
||||||
// Filter subcategories by category and separate into regular, advanced, and unused
|
// Filter subcategories by category and separate into regular, advanced, and unused
|
||||||
var regularSubcategories = [];
|
var regularSubcategories = [];
|
||||||
|
|||||||
@ -70,10 +70,112 @@ class TopbarComponent {
|
|||||||
this.setupLogout();
|
this.setupLogout();
|
||||||
this.setupConfigUpdateLockout();
|
this.setupConfigUpdateLockout();
|
||||||
this.setupSetupGate();
|
this.setupSetupGate();
|
||||||
|
this.setupDevModeEasterEgg();
|
||||||
|
this.autoEnableDevModeIfNeeded();
|
||||||
// Mount the "out of date" badge now that .topbar-controls exists.
|
// Mount the "out of date" badge now that .topbar-controls exists.
|
||||||
window.updateNotifier?.onTopbarReady();
|
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.
|
// Disable nav items entirely until the Setup Wizard has been completed.
|
||||||
// The wizard itself runs as a full-screen overlay that blocks interaction;
|
// 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
|
// this is a belt-and-braces guard for the brief window before the wizard
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user