/** * Environment Configuration & Utilities * Central config dictionary and environment discovery */ import { configureKeyValueStoreBackend, getProvider } from './storage.js'; import { api as apiClient } from './api.js'; // Private storage instance for config const storage = getProvider('kv', 'config'); // Config dictionary - populated by loader let config = {}; let consoleLoggerInstalled = false; const lockedKeys = new Set(); const LOGGER_ICONS = { debug: '.', info: 'i', log: '>', warn: '!', error: 'x' }; const NATIVE_CONSOLE = { log: console.log.bind(console), info: (console.info || console.log).bind(console), warn: console.warn.bind(console), error: console.error.bind(console), debug: (console.debug || console.log).bind(console) }; const perfNow = () => { if (typeof performance !== 'undefined' && typeof performance.now === 'function') { return performance.now(); } return Date.now(); }; function normalizeLogLevel(level = 'log') { if (level === 'info') return 'info'; if (level === 'warn') return 'warn'; if (level === 'error') return 'error'; if (level === 'debug') return 'debug'; return 'log'; } function splitSourceAndMessage(args = []) { if (args.length === 0) { return { source: 'App', messageParts: [] }; } const [firstArg, ...restArgs] = args; if (typeof firstArg !== 'string') { return { source: 'App', messageParts: args }; } const match = firstArg.match(/^\[([^\]]+)\]\s*(.*)$/s); if (!match) { return { source: 'App', messageParts: args }; } const [, source, firstMessage] = match; const messageParts = firstMessage ? [firstMessage, ...restArgs] : restArgs; return { source, messageParts }; } // Special config keys export const CONFIG_KEYS = { APP_NAME: 'APP_NAME', APP_DISPLAY_NAME: 'APP_DISPLAY_NAME', APP_DESCRIPTION: 'APP_DESCRIPTION', FAVICON: 'FAVICON', BRAND_LOGO: 'BRAND_LOGO', THEME_COLOR: 'THEME_COLOR', BACKGROUND_COLOR: 'BACKGROUND_COLOR', UI_SHELL: 'UI_SHELL', STYLE_THEME: 'STYLE_THEME', INITIAL_ROUTE: 'INITIAL_ROUTE', STORAGE_BACKEND: 'STORAGE_BACKEND', API_BASE_URL: 'API_BASE_URL', MODULES: 'MODULES', SECURITY_CONFIG: 'SECURITY_CONFIG', LOCALE: 'LOCALE', /** Development host: extra dev UI, SW dev behavior, etc. Layered via getConfig (storage → profile → bundler). */ DEV_HOST: 'DEV_HOST', /** Whether the app should register and use a service worker. */ SERVICE_WORKER_ENABLED: 'SERVICE_WORKER_ENABLED' }; // do not allow edits on these keys via setConfig - they are meant to be set from profile or env vars and not overridden at runtime [ CONFIG_KEYS.API_BASE_URL, CONFIG_KEYS.MODULES, CONFIG_KEYS.SECURITY_CONFIG ].forEach((key) => lockedKeys.add(key)); /** * Resolve dev-host flag from profile (explicit) or bundler hints. * Profile wins: `dev_host: true|false` or `runtime.dev: true|false`. * Otherwise Vite `import.meta.env.DEV`, else false. * @param {Object|null|undefined} appConfig * @returns {boolean} */ function resolveDevHostFlag(appConfig) { if (appConfig && typeof appConfig.dev_host === 'boolean') { return appConfig.dev_host; } if (appConfig?.runtime && typeof appConfig.runtime.dev === 'boolean') { return appConfig.runtime.dev; } if (typeof import.meta !== 'undefined' && import.meta.env) { return Boolean(import.meta.env.DEV); } return false; } function resolveDesktopApiBaseURL() { if (typeof window === 'undefined') { return null; } const bridgeBase = window.__bfaceDesktop?.apiBaseUrl; if (typeof bridgeBase === 'string' && bridgeBase.trim()) { return bridgeBase.trim(); } return null; } /** * Resolve service-worker enablement from profile. * Defaults to true for backward compatibility with PWA templates. * @param {Object|null|undefined} appConfig * @returns {boolean} */ export function resolveServiceWorkerEnabled(appConfig = {}) { if (typeof appConfig?.service_worker?.enabled === 'boolean') { return appConfig.service_worker.enabled; } if (typeof appConfig?.serviceWorker?.enabled === 'boolean') { return appConfig.serviceWorker.enabled; } if (typeof appConfig?.pwa?.service_worker?.enabled === 'boolean') { return appConfig.pwa.service_worker.enabled; } return true; } /** * Initialize environment config * @param {Object} appConfig - Configuration from profile */ export function initEnv(appConfig) { const api = appConfig.api || {}; const resolvedApiBaseURL = resolveDesktopApiBaseURL() || appConfig.backend?.base_url || api.base_url || api.baseURL || '/api'; config = { APP_NAME: appConfig.id || appConfig.name, APP_DISPLAY_NAME: appConfig.displayName || appConfig.short_name || appConfig.name, APP_DESCRIPTION: appConfig.description || '', FAVICON: appConfig.favicon || appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg', BRAND_LOGO: appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg', THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000', BACKGROUND_COLOR: appConfig.background_color || appConfig.backgroundColor || '#ffffff', UI_SHELL: appConfig.ui_shell || appConfig.uiShell || 'EmptyShell', STYLE_THEME: appConfig.style_theme || appConfig.styleTheme || appConfig.ui?.style_theme || appConfig.ui?.styleTheme || null, INITIAL_ROUTE: appConfig.initial_route || appConfig.initialRoute || appConfig.ui?.initial_route || appConfig.ui?.initialRoute || '/home', STORAGE_BACKEND: appConfig.storage?.backend || 'localStorage', API_BASE_URL: resolvedApiBaseURL, MODULES: appConfig.modules || [], SECURITY_CONFIG: appConfig.security || {}, LOCALE: appConfig.locale || null, [CONFIG_KEYS.DEV_HOST]: resolveDevHostFlag(appConfig), [CONFIG_KEYS.SERVICE_WORKER_ENABLED]: resolveServiceWorkerEnabled(appConfig), // Store full profile for advanced access _profile: appConfig }; apiClient.setBaseURL(resolvedApiBaseURL); configureKeyValueStoreBackend(config.STORAGE_BACKEND); } function resolveSystemLocale() { if (typeof navigator !== 'undefined' && typeof navigator.language === 'string' && navigator.language.trim()) { return navigator.language.trim(); } return 'en-US'; } /** * Bootstrap environment as early as possible from the selected profile. * This gives the app a single source of truth before full boot runs. * @param {Object} appConfig * @param {Object} options * @param {boolean} [options.installLoggerFirst=true] */ export function bootstrapEnv(appConfig, options = {}) { const { installLoggerFirst = true } = options; if (installLoggerFirst) { installConsoleLogger(); } if (appConfig && typeof appConfig === 'object') { initEnv(appConfig); } return getConfigDict(); } export function log(source = 'App', level = 'log', ...messageParts) { const normalizedLevel = normalizeLogLevel(level); const sink = NATIVE_CONSOLE[normalizedLevel] || NATIVE_CONSOLE.log; const timestamp = new Date().toISOString(); const icon = LOGGER_ICONS[normalizedLevel] || LOGGER_ICONS.log; sink(`${timestamp} [${source}] ${icon}`, ...messageParts); } export function createLogger(source = 'App') { return { debug: (...messageParts) => log(source, 'debug', ...messageParts), info: (...messageParts) => log(source, 'info', ...messageParts), log: (...messageParts) => log(source, 'log', ...messageParts), warn: (...messageParts) => log(source, 'warn', ...messageParts), error: (...messageParts) => log(source, 'error', ...messageParts) }; } export function startTrace(source = 'App', label = 'operation', metadata = null) { const startedAt = perfNow(); if (metadata !== null && metadata !== undefined) { log(source, 'debug', `${label} started`, metadata); } else { log(source, 'debug', `${label} started`); } return { end(extra = null, level = 'log') { const durationMs = Math.round((perfNow() - startedAt) * 100) / 100; if (extra !== null && extra !== undefined) { log(source, level, `${label} finished in ${durationMs}ms`, extra); } else { log(source, level, `${label} finished in ${durationMs}ms`); } return durationMs; }, fail(error = null) { const durationMs = Math.round((perfNow() - startedAt) * 100) / 100; log(source, 'error', `${label} failed in ${durationMs}ms`, error); return durationMs; } }; } export async function traceAsync(source, label, fn, metadata = null) { const trace = startTrace(source, label, metadata); try { const result = await fn(); trace.end(); return result; } catch (error) { trace.fail(error); throw error; } } export function installConsoleLogger() { if (consoleLoggerInstalled || typeof console === 'undefined') { return; } consoleLoggerInstalled = true; console.log = (...args) => { const { source, messageParts } = splitSourceAndMessage(args); log(source, 'log', ...messageParts); }; console.info = (...args) => { const { source, messageParts } = splitSourceAndMessage(args); log(source, 'info', ...messageParts); }; console.warn = (...args) => { const { source, messageParts } = splitSourceAndMessage(args); log(source, 'warn', ...messageParts); }; console.error = (...args) => { const { source, messageParts } = splitSourceAndMessage(args); log(source, 'error', ...messageParts); }; console.debug = (...args) => { const { source, messageParts } = splitSourceAndMessage(args); log(source, 'debug', ...messageParts); }; } export function lockConfigKey(key) { if (!key) { return false; } lockedKeys.add(key); return true; } export function unlockConfigKey(key) { if (!key) { return false; } return lockedKeys.delete(key); } export function isConfigKeyLocked(key) { return lockedKeys.has(key); } export function getLockedConfigKeys() { return Array.from(lockedKeys); } function setMetaTag(name, content) { if (typeof document === 'undefined') { return; } let element = document.querySelector(`meta[name="${name}"]`); if (!element) { element = document.createElement('meta'); element.setAttribute('name', name); document.head.appendChild(element); } element.setAttribute('content', content); } export function setDocumentBackground(color) { if (typeof document === 'undefined' || !color) { return; } document.documentElement.style.backgroundColor = color; if (document.body) { document.body.style.backgroundColor = color; } } function setFavicon(href) { if (typeof document === 'undefined' || !href) { return; } let element = document.querySelector('link[rel="icon"]'); if (!element) { element = document.createElement('link'); element.setAttribute('rel', 'icon'); document.head.appendChild(element); } element.setAttribute('href', href); if (!element.getAttribute('type')) { element.setAttribute('type', 'image/svg+xml'); } } export async function syncDocumentHeadFromConfig(options = {}) { if (typeof document === 'undefined') { return null; } const { titleFallback = 'PWA Template', descriptionFallback = '', themeColorFallback = '#000000', backgroundColorFallback = '#ffffff' } = options; const [favicon, title, description, themeColor, backgroundColor, brandLogo] = await Promise.all([ getConfig(CONFIG_KEYS.FAVICON, '/favicon.svg'), getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, titleFallback), getConfig(CONFIG_KEYS.APP_DESCRIPTION, descriptionFallback), getConfig(CONFIG_KEYS.THEME_COLOR, themeColorFallback), getConfig(CONFIG_KEYS.BACKGROUND_COLOR, backgroundColorFallback), getConfig(CONFIG_KEYS.BRAND_LOGO, '/favicon.svg') ]); document.title = title || titleFallback; setMetaTag('description', description || descriptionFallback); setMetaTag('theme-color', themeColor || themeColorFallback); setDocumentBackground(backgroundColor || backgroundColorFallback); setFavicon(favicon || '/favicon.svg'); return { title: title || titleFallback, description: description || descriptionFallback, themeColor: themeColor || themeColorFallback, backgroundColor: backgroundColor || backgroundColorFallback, favicon: favicon || '/favicon.svg', brandLogo: brandLogo || '/favicon.svg' }; } /** * Get config value by key * Checks in order: storage (if key exists) → config dictionary → environment variables → altValue * Uses storage.hasKey() as a guard to skip unnecessary storage.get() calls for profile config keys * @param {string} key - Config key * @param {any} altValue - Alternative value if not found * @returns {Promise} Config value or altValue */ export function getConfigSync(key, altValue = null) { if (config.hasOwnProperty(key)) { return config[key]; } if (typeof import.meta !== 'undefined' && import.meta.env) { const envKey = `VITE_${key}`; const envValue = import.meta.env[envKey]; if (envValue !== undefined) { return envValue; } } return altValue; } export async function getConfig(key, altValue = null) { // Locked profile keys cannot be overridden by persisted storage values. if (isConfigKeyLocked(key) && config.hasOwnProperty(key)) { return config[key]; } // 1. Check if key exists in storage first (quick check to avoid unnecessary get) try { const exists = await storage.hasKey(key); if (exists) { // Only do full storage.get() if key exists const storedValue = await storage.get(key, null); if (storedValue !== null && storedValue !== undefined) { return storedValue; } } // If key doesn't exist in storage, skip to config dictionary (faster path) } catch (error) { // Storage might not be available, continue to next check console.warn(`[Env] Failed to check storage for "${key}":`, error); } // 2. Check config dictionary (in-memory config from profile) if (config.hasOwnProperty(key)) { return config[key]; } // 3. Fall back to environment variables (Vite env vars) // Check if import.meta.env exists (available in Vite, not in Node) if (typeof import.meta !== 'undefined' && import.meta.env) { const envKey = `VITE_${key}`; const envValue = import.meta.env[envKey]; if (envValue !== undefined) { return envValue; } } // 4. Return alternative value return altValue; } /** * Set config value * If key exists in config dictionary, update it there (in-memory). * Otherwise, save to storage (for persistence). * @param {string} key - Config key * @param {any} value - Value to set * @returns {Promise} */ export async function setConfig(key, value) { if (isConfigKeyLocked(key)) { console.warn(`[Env] Refusing to set locked config key "${key}". Unlock it explicitly first if this override is intentional.`); return false; } // If key exists in config dictionary, update it there (in-memory config) if (config.hasOwnProperty(key)) { config[key] = value; return true; } else { // Otherwise, save to storage (for state/settings persistence) try { await storage.set(key, value); return true; } catch (error) { console.warn(`[Env] Failed to set "${key}" in storage:`, error); // Fallback: store in config dictionary if storage fails config[key] = value; return true; } } } /** * Get full config object (read-only copy) * @returns {Object} Config dictionary */ export function getConfigDict() { return { ...config }; } export async function getLocale(altValue = null) { const savedLocale = await getConfig(CONFIG_KEYS.LOCALE, null); if (savedLocale && typeof savedLocale === 'string') { config[CONFIG_KEYS.LOCALE] = savedLocale; return savedLocale; } return altValue || resolveSystemLocale(); } export function getLocaleSync(altValue = null) { return config[CONFIG_KEYS.LOCALE] || altValue || resolveSystemLocale(); } export async function setLocale(locale) { const normalizedLocale = typeof locale === 'string' && locale.trim() ? locale.trim() : null; config[CONFIG_KEYS.LOCALE] = normalizedLocale; try { if (normalizedLocale) { await storage.set(CONFIG_KEYS.LOCALE, normalizedLocale); } else { await storage.remove(CONFIG_KEYS.LOCALE); } } catch (error) { console.warn('[Env] Failed to persist locale:', error); } return normalizedLocale || resolveSystemLocale(); } /** * Whether the app is treated as a development host (dev tooling, SW policy, etc.). * Synchronous: uses in-memory config after {@link initEnv} / {@link bootstrapEnv}. * For persisted overrides, use {@link getConfig} with {@link CONFIG_KEYS.DEV_HOST} in async code. */ export function isDevelopment() { if (config && Object.prototype.hasOwnProperty.call(config, CONFIG_KEYS.DEV_HOST)) { return Boolean(config[CONFIG_KEYS.DEV_HOST]); } if (typeof import.meta !== 'undefined' && import.meta.env) { return Boolean(import.meta.env.DEV); } return false; } /** * Whether service worker registration is enabled for this app. * Synchronous: uses in-memory config after {@link initEnv} / {@link bootstrapEnv}. */ export function isServiceWorkerEnabledSync(altValue = true) { if (config && Object.prototype.hasOwnProperty.call(config, CONFIG_KEYS.SERVICE_WORKER_ENABLED)) { return Boolean(config[CONFIG_KEYS.SERVICE_WORKER_ENABLED]); } return altValue; } /** * Async profile-aware service worker enablement check. */ export async function isServiceWorkerEnabled(altValue = true) { const configured = await getConfig(CONFIG_KEYS.SERVICE_WORKER_ENABLED, null); if (typeof configured === 'boolean') { return configured; } return isServiceWorkerEnabledSync(altValue); } /** * Inverse of {@link isDevelopment} when no explicit production flag exists. */ export function isProduction() { if (typeof import.meta !== 'undefined' && import.meta.env && typeof import.meta.env.PROD === 'boolean') { return import.meta.env.PROD; } return !isDevelopment(); }