From aa872bdd6b4af4701e6ed49d36d30a7ed82a26a9 Mon Sep 17 00:00:00 2001 From: Amer Agovic Date: Tue, 16 Jun 2026 16:44:32 -0500 Subject: [PATCH] Release 1.0.10 with subpath deployment and modal menu invokes. Add app_base/router_base config, compat path helpers, and scoped service worker registration so apps can mount under a URL prefix. Wire invoke_type modal through a handler registry and open the notification center from the standard menu flow. Co-authored-by: Cursor --- package.json | 2 +- src/platform/compat.js | 148 ++++++++++++++++++++++++++++++++--- src/platform/env.js | 89 ++++++++++++++++++++- src/platform/menu.js | 47 ++++++++++- src/platform/worker.js | 45 +++++++++-- src/ui/components/Router.jsx | 11 +-- src/ui/components/Shell.jsx | 5 ++ test/compat-paths.test.js | 38 +++++++++ test/env.test.js | 29 +++++++ 9 files changed, 381 insertions(+), 33 deletions(-) create mode 100644 test/compat-paths.test.js diff --git a/package.json b/package.json index a5dd227..0db5524 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@reliancy/bface", - "version": "1.0.8", + "version": "1.0.10", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", diff --git a/src/platform/compat.js b/src/platform/compat.js index 812b2ac..ecb98b0 100644 --- a/src/platform/compat.js +++ b/src/platform/compat.js @@ -7,7 +7,7 @@ * added when needed. */ -import { CONFIG_KEYS, getConfig, setConfig } from './env.js'; +import { CONFIG_KEYS, getConfig, getConfigSync, setConfig, normalizeRouterBase } from './env.js'; import { getDesktopBridgeSafe, getHostKind, isElectronHost } from './host.js'; // Config key for last visited route path @@ -640,6 +640,124 @@ export async function getHostInfo() { // URL/Path Management (Browser URL synchronization) // ============================================================================ +export { normalizeRouterBase, resolveProfileBases } from './env.js'; + +/** + * Vite/public asset base path with trailing slash (e.g. `/admin/`). + * @param {string} routerBase + * @returns {string} + */ +export function getViteBasePath(routerBase = '/') { + const normalized = normalizeRouterBase(routerBase); + if (normalized === '/') { + return '/'; + } + return `${normalized}/`; +} + +/** + * Resolve router base from env config, falling back to the bundler base URL. + * @returns {string} + */ +export function getRouterBaseFromMeta() { + if (typeof document !== 'undefined') { + const baseElement = document.querySelector('base[href]'); + if (baseElement) { + try { + const href = baseElement.getAttribute('href') || '/'; + const resolved = new URL(href, window.location.origin); + return normalizeRouterBase(resolved.pathname || '/'); + } catch { + // Fall through to other hints. + } + } + } + + if (typeof globalThis !== 'undefined' && globalThis.__BFACE_ROUTER_BASE__) { + return normalizeRouterBase(globalThis.__BFACE_ROUTER_BASE__); + } + + return '/'; +} + +function resolveActiveRouterBase() { + const fromConfig = getConfigSync(CONFIG_KEYS.ROUTER_BASE, null); + if (fromConfig != null) { + return normalizeRouterBase(fromConfig); + } + + if (typeof globalThis !== 'undefined' && globalThis.__BFACE_ROUTER_BASE__) { + return normalizeRouterBase(globalThis.__BFACE_ROUTER_BASE__); + } + + return getRouterBaseFromMeta(); +} + +/** + * Strip the public router base from a browser pathname. + * @param {string} pathname + * @param {string} [routerBase] + * @returns {string} + */ +export function stripRouterBase(pathname = '/', routerBase = resolveActiveRouterBase()) { + const path = String(pathname || '/'); + const base = normalizeRouterBase(routerBase); + if (!base || base === '/') { + return path || '/'; + } + if (path === base) { + return '/'; + } + if (path.startsWith(`${base}/`)) { + return path.slice(base.length) || '/'; + } + // Ignore paths outside the app mount (stale /home redirects, foreign host routes). + return '/'; +} + +/** + * Prefix an internal route path with the public router base. + * @param {string} routePath + * @param {string} [routerBase] + * @returns {string} + */ +export function withRouterBase(routePath = '/', routerBase = resolveActiveRouterBase()) { + const path = routePath.startsWith('/') ? routePath : `/${routePath}`; + const base = normalizeRouterBase(routerBase); + if (!base || base === '/') { + return path; + } + if (path === '/') { + return `${base}/`; + } + return `${base}${path}`; +} + +/** + * Prefix a root-relative public asset path with the app base. + * Leaves `/api` and already-prefixed paths unchanged. + * @param {string} path + * @param {string} [appBase] + * @returns {string} + */ +export function prefixPublicAssetPath(path = '', appBase = '/') { + const normalized = String(path || '').trim(); + if (!normalized || !normalized.startsWith('/')) { + return normalized; + } + if (normalized.startsWith('/api')) { + return normalized; + } + const base = normalizeRouterBase(appBase); + if (base === '/') { + return normalized; + } + if (normalized === base || normalized.startsWith(`${base}/`)) { + return normalized; + } + return `${base}${normalized}`; +} + /** * Get router path (current path the router should use) * Checks browser URL first, then localStorage for last visited path, then falls back to default @@ -648,12 +766,13 @@ export async function getHostInfo() { * @returns {Promise} Router path to use */ export async function getRouterPath(defaultPath = '/') { + const routerBase = resolveActiveRouterBase(); + // First, check browser URL (web only) if (typeof window !== 'undefined' && window.location) { - const urlPath = window.location.pathname; - // If URL has a meaningful path (not just '/'), use it - if (urlPath && urlPath !== '/') { - return urlPath; + const internalPath = stripRouterBase(window.location.pathname, routerBase); + if (internalPath && internalPath !== '/') { + return internalPath; } } @@ -688,19 +807,21 @@ export async function getRouterPath(defaultPath = '/') { * Web: Uses history.pushState or history.replaceState * React Native: Only saves to storage (no URL to update) * - * @param {string} path - Path to navigate to (e.g., '/dashboard') + * @param {string} path - Internal router path to navigate to (e.g., '/dashboard') * @param {boolean} [replace=false] - Whether to replace current history entry */ export async function setRouterPath(path, replace = false, options = {}) { const { notify = true, state = null } = options; - const fullPath = path.startsWith('/') ? path : '/' + path; + const routerBase = resolveActiveRouterBase(); + const internalPath = path.startsWith('/') ? path : `/${path}`; + const publicPath = withRouterBase(internalPath, routerBase); // Update browser URL (web only) if (typeof window !== 'undefined' && window.history) { if (replace) { - window.history.replaceState(state, '', fullPath); + window.history.replaceState(state, '', publicPath); } else { - window.history.pushState(state, '', fullPath); + window.history.pushState(state, '', publicPath); } if (notify) { @@ -712,14 +833,14 @@ export async function setRouterPath(path, replace = false, options = {}) { } } - // Save to config for persistence (works on all platforms) + // Save internal path for persistence (works on all platforms) try { const scopedLastPathKey = await getScopedLastPathConfigKey(); - await setConfig(scopedLastPathKey, fullPath); + await setConfig(scopedLastPathKey, internalPath); const appName = await getConfig(CONFIG_KEYS.APP_NAME, DEFAULT_APP_NAME); if (appName === DEFAULT_APP_NAME) { - await setConfig(LAST_PATH_CONFIG_KEY, fullPath); + await setConfig(LAST_PATH_CONFIG_KEY, internalPath); } } catch (error) { console.warn('[Compat] Failed to save path to config:', error); @@ -741,7 +862,8 @@ export function subscribeToPathChanges(listener) { } const handlePopState = (event) => { - const path = window.location.pathname; + const routerBase = resolveActiveRouterBase(); + const path = stripRouterBase(window.location.pathname, routerBase); try { listener(path, event.state); } catch (error) { diff --git a/src/platform/env.js b/src/platform/env.js index 347a672..52ae44a 100644 --- a/src/platform/env.js +++ b/src/platform/env.js @@ -96,14 +96,20 @@ export const CONFIG_KEYS = { /** 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' + SERVICE_WORKER_ENABLED: 'SERVICE_WORKER_ENABLED', + /** Public deployment prefix (Vite base, static assets). No trailing slash except `/`. */ + APP_BASE: 'APP_BASE', + /** URL prefix owned by the in-app router. Defaults to APP_BASE. */ + ROUTER_BASE: 'ROUTER_BASE' }; // 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 + CONFIG_KEYS.SECURITY_CONFIG, + CONFIG_KEYS.APP_BASE, + CONFIG_KEYS.ROUTER_BASE ].forEach((key) => lockedKeys.add(key)); /** @@ -158,6 +164,72 @@ export function resolveServiceWorkerEnabled(appConfig = {}) { return true; } +/** + * Normalize a router/app base path to a leading slash without a trailing slash. + * @param {string} value + * @returns {string} + */ +export function normalizeRouterBase(value = '/') { + const raw = String(value ?? '/').trim() || '/'; + if (raw === '/') { + return '/'; + } + const withLeading = raw.startsWith('/') ? raw : `/${raw}`; + return withLeading.replace(/\/+$/, ''); +} + +/** + * Resolve app/router base paths from a profile object. + * @param {Object} appConfig + * @returns {{ app_base: string, router_base: string }} + */ +export function resolveProfileBases(appConfig = {}) { + const rawAppBase = + appConfig.app_base + ?? appConfig.appBase + ?? appConfig.ui?.app_base + ?? appConfig.ui?.appBase + ?? null; + const rawRouterBase = + appConfig.router_base + ?? appConfig.routerBase + ?? appConfig.ui?.router_base + ?? appConfig.ui?.routerBase + ?? null; + + if (rawAppBase == null && rawRouterBase == null) { + return { app_base: '/', router_base: '/' }; + } + + if (rawAppBase != null && rawRouterBase != null) { + return { + app_base: normalizeRouterBase(rawAppBase), + router_base: normalizeRouterBase(rawRouterBase) + }; + } + + const single = normalizeRouterBase(rawAppBase ?? rawRouterBase); + return { app_base: single, router_base: single }; +} + +function prefixPublicAssetPath(path = '', appBase = '/') { + const normalized = String(path || '').trim(); + if (!normalized || !normalized.startsWith('/')) { + return normalized; + } + if (normalized.startsWith('/api')) { + return normalized; + } + const base = normalizeRouterBase(appBase); + if (base === '/') { + return normalized; + } + if (normalized === base || normalized.startsWith(`${base}/`)) { + return normalized; + } + return `${base}${normalized}`; +} + /** * Initialize environment config * @param {Object} appConfig - Configuration from profile @@ -170,12 +242,15 @@ export function initEnv(appConfig) { || api.base_url || api.baseURL || '/api'; + const { app_base, router_base } = resolveProfileBases(appConfig); + const faviconPath = appConfig.favicon || appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg'; + const brandLogoPath = appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg'; 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', + FAVICON: prefixPublicAssetPath(faviconPath, app_base), + BRAND_LOGO: prefixPublicAssetPath(brandLogoPath, app_base), THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000', BACKGROUND_COLOR: appConfig.background_color || appConfig.backgroundColor || '#ffffff', UI_SHELL: appConfig.ui_shell || appConfig.uiShell || 'EmptyShell', @@ -193,9 +268,15 @@ export function initEnv(appConfig) { LOCALE: appConfig.locale || null, [CONFIG_KEYS.DEV_HOST]: resolveDevHostFlag(appConfig), [CONFIG_KEYS.SERVICE_WORKER_ENABLED]: resolveServiceWorkerEnabled(appConfig), + [CONFIG_KEYS.APP_BASE]: app_base, + [CONFIG_KEYS.ROUTER_BASE]: router_base, // Store full profile for advanced access _profile: appConfig }; + if (typeof globalThis !== 'undefined') { + globalThis.__BFACE_APP_BASE__ = app_base; + globalThis.__BFACE_ROUTER_BASE__ = router_base; + } apiClient.setBaseURL(resolvedApiBaseURL); configureKeyValueStoreBackend(config.STORAGE_BACKEND); } diff --git a/src/platform/menu.js b/src/platform/menu.js index 026fd66..c2eec99 100644 --- a/src/platform/menu.js +++ b/src/platform/menu.js @@ -72,10 +72,55 @@ export const InvokeHandlers = { * @param {Event|null} event - Original event object */ goToModal: (menuItem, eventSource = null, event = null) => { - // To be implemented in App.jsx + return invokeRegisteredModal(menuItem, eventSource, event); } }; +const modalHandlers = new Map(); + +/** + * Register a handler for invoke_type "modal" targets (e.g. notifications panel). + * @param {string} target - invoke_target value from the menu item + * @param {Function} handler - (menuItem, eventSource, event) => any + * @returns {Function} Unregister function + */ +export function registerModalHandler(target, handler) { + const key = String(target || '').trim(); + if (!key || typeof handler !== 'function') { + return () => {}; + } + + modalHandlers.set(key, handler); + return () => { + modalHandlers.delete(key); + }; +} + +/** + * Resolve a registered modal handler by invoke_target. + * @param {string} target + * @returns {Function|null} + */ +export function resolveModalHandler(target) { + const key = String(target || '').trim(); + return key ? modalHandlers.get(key) || null : null; +} + +export function invokeRegisteredModal(menuItem, eventSource = null, event = null) { + if (!menuItem?.invoke_target) { + console.warn('[MenuItem] invoke_type "modal" requires invoke_target to be set'); + return null; + } + + const handler = resolveModalHandler(menuItem.invoke_target); + if (!handler) { + console.warn(`[MenuItem] No modal handler registered for "${menuItem.invoke_target}"`); + return null; + } + + return handler(menuItem, eventSource, event); +} + /** * Subscribe to menu changes * @param {Function} listener - Callback function called when menu changes diff --git a/src/platform/worker.js b/src/platform/worker.js index bd0c72a..7399b92 100644 --- a/src/platform/worker.js +++ b/src/platform/worker.js @@ -7,14 +7,14 @@ import { isElectronHost, isTauriHost } from './host.js'; import { getConfig, + getConfigSync, isDevelopment, isServiceWorkerEnabled, isServiceWorkerEnabledSync, + normalizeRouterBase, CONFIG_KEYS } from './env.js'; -const SW_PATH = '/sw.js'; -const SW_SCOPE = '/'; const DEV_SW_RESET_KEY = '__bface_dev_sw_reset__'; /** Bit flags for {@link clearPWACache}. Combine with `|`. */ @@ -243,9 +243,10 @@ async function executeServiceWorkerPolicy(policy) { } let registration = await resolveServiceWorkerRegistration(); + const { scriptURL, scope } = resolveServiceWorkerMount(); if (!registration) { - registration = await navigator.serviceWorker.register(SW_PATH, { - scope: SW_SCOPE, + registration = await navigator.serviceWorker.register(scriptURL, { + scope, updateViaCache: 'none' }); console.log('Service Worker registered:', registration); @@ -312,14 +313,46 @@ async function resetServiceWorkers() { return true; } +function resolveServiceWorkerMount() { + const fromConfig = getConfigSync(CONFIG_KEYS.APP_BASE, null); + const appBase = normalizeRouterBase( + fromConfig + ?? (typeof globalThis !== 'undefined' ? globalThis.__BFACE_APP_BASE__ : null) + ?? '/' + ); + + if (appBase === '/') { + return { scriptURL: '/sw.js', scope: '/' }; + } + + return { + scriptURL: `${appBase}/sw.js`, + scope: `${appBase}/` + }; +} + async function resolveServiceWorkerRegistration() { if (!hasServiceWorkerSupport()) { return null; } + const { scope } = resolveServiceWorkerMount(); + try { - return await navigator.serviceWorker.getRegistration(SW_SCOPE) - || await navigator.serviceWorker.getRegistration(); + const scopedRegistration = await navigator.serviceWorker.getRegistration(scope); + if (scopedRegistration) { + return scopedRegistration; + } + + if (scope !== '/') { + const rootRegistration = await navigator.serviceWorker.getRegistration('/'); + if (rootRegistration) { + console.log('[SW] Removing stale root-scoped registration:', rootRegistration.scope); + await rootRegistration.unregister(); + } + } + + return null; } catch (error) { console.warn('[SW] Failed to read service worker registration:', error); return null; diff --git a/src/ui/components/Router.jsx b/src/ui/components/Router.jsx index e695155..6156287 100644 --- a/src/ui/components/Router.jsx +++ b/src/ui/components/Router.jsx @@ -6,7 +6,7 @@ import React, { Suspense, createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Spinner, YStack } from 'tamagui'; -import { InvokeHandlers } from '../../platform/menu.js'; +import { InvokeHandlers, invokeRegisteredModal } from '../../platform/menu.js'; import { openExternalURL, getRouterPath, setRouterPath, subscribeToPathChanges } from '../../platform/compat.js'; import { ErrorPage } from '../../security/pages/ErrorPage.jsx'; import { evaluateRouteAccess } from '../../security/runtime/route-guards.js'; @@ -883,14 +883,9 @@ export function Router({ initialPath = '/', children, onRouteChange }) { } }; - // Set up goToModal handler (placeholder - to be implemented) InvokeHandlers.goToModal = (menuItem, eventSource, event) => { - if (menuItem.invoke_target) { - routerDebug('[Router] Modal not yet implemented:', menuItem.invoke_target); - // TODO: Implement modal system - } else { - console.warn('[Router] MenuItem missing invoke_target for goToModal'); - } + routerDebug('[Router] Opening modal:', menuItem.invoke_target); + return invokeRegisteredModal(menuItem, eventSource, event); }; routerDebug('[Router] InvokeHandlers configured'); diff --git a/src/ui/components/Shell.jsx b/src/ui/components/Shell.jsx index 8a60c57..b338de6 100644 --- a/src/ui/components/Shell.jsx +++ b/src/ui/components/Shell.jsx @@ -10,6 +10,7 @@ import RecordsModel from '../../data/RecordsModel.js'; import { getIcon } from './IconMapper.jsx'; import { SidePanelShell } from './SidePanelShell.jsx'; import { networkActivityManager } from '../../platform/api.js'; +import { registerModalHandler } from '../../platform/menu.js'; // ============================================================================ // Shell Context @@ -572,6 +573,10 @@ class NotificationCenterManager { const notificationCenterManager = new NotificationCenterManager(); +registerModalHandler('notifications', () => { + notificationCenterManager.open(); +}); + function getNotificationTypeStyle(type = 'info') { return { info: { diff --git a/test/compat-paths.test.js b/test/compat-paths.test.js new file mode 100644 index 0000000..6129e14 --- /dev/null +++ b/test/compat-paths.test.js @@ -0,0 +1,38 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { + getViteBasePath, + prefixPublicAssetPath, + stripRouterBase, + withRouterBase +} from '../src/platform/compat.js'; +import { initEnv, getConfigSync, CONFIG_KEYS } from '../src/platform/env.js'; + +describe('compat path helpers', () => { + it('normalizes vite base paths', () => { + assert.strictEqual(getViteBasePath('/'), '/'); + assert.strictEqual(getViteBasePath('/admin'), '/admin/'); + assert.strictEqual(getViteBasePath('/admin/'), '/admin/'); + }); + + it('maps internal routes to public URLs and back', () => { + assert.strictEqual(withRouterBase('/home', '/admin'), '/admin/home'); + assert.strictEqual(withRouterBase('/', '/admin'), '/admin/'); + assert.strictEqual(stripRouterBase('/admin/home', '/admin'), '/home'); + assert.strictEqual(stripRouterBase('/admin/', '/admin'), '/'); + assert.strictEqual(stripRouterBase('/admin', '/admin'), '/'); + assert.strictEqual(stripRouterBase('/home', '/admin'), '/'); + }); + + it('prefixes public asset paths but not /api', () => { + assert.strictEqual(prefixPublicAssetPath('/brand-logo.svg', '/admin'), '/admin/brand-logo.svg'); + assert.strictEqual(prefixPublicAssetPath('/api', '/admin'), '/api'); + assert.strictEqual(prefixPublicAssetPath('/admin/logo.svg', '/admin'), '/admin/logo.svg'); + }); + + it('seeds router base into env config', () => { + initEnv({ id: 'demo', app_base: '/admin' }); + assert.strictEqual(getConfigSync(CONFIG_KEYS.APP_BASE), '/admin'); + assert.strictEqual(getConfigSync(CONFIG_KEYS.ROUTER_BASE), '/admin'); + }); +}); diff --git a/test/env.test.js b/test/env.test.js index 4c03640..3f0f605 100644 --- a/test/env.test.js +++ b/test/env.test.js @@ -15,6 +15,8 @@ import { isProduction, isServiceWorkerEnabledSync, resolveServiceWorkerEnabled, + resolveProfileBases, + normalizeRouterBase, CONFIG_KEYS } from '../src/platform/env.js'; import { getProvider } from '../src/platform/storage.js'; @@ -201,6 +203,33 @@ describe('env.js', () => { }); }); + describe('profile base paths', () => { + test('resolveProfileBases defaults to root', () => { + assert.deepStrictEqual(resolveProfileBases({}), { app_base: '/', router_base: '/' }); + }); + + test('resolveProfileBases mirrors a single configured base', () => { + assert.deepStrictEqual(resolveProfileBases({ app_base: '/admin/' }), { + app_base: '/admin', + router_base: '/admin' + }); + }); + + test('resolveProfileBases allows separate router base', () => { + assert.deepStrictEqual(resolveProfileBases({ app_base: '/admin', router_base: '/console' }), { + app_base: '/admin', + router_base: '/console' + }); + }); + + test('initEnv seeds APP_BASE and ROUTER_BASE', () => { + initEnv({ id: 'demo', router_base: 'ops' }); + assert.strictEqual(getConfigSync(CONFIG_KEYS.ROUTER_BASE), '/ops'); + assert.strictEqual(getConfigSync(CONFIG_KEYS.APP_BASE), '/ops'); + assert.strictEqual(normalizeRouterBase('/ops/'), '/ops'); + }); + }); + describe('isDevelopment and isProduction', () => { test('should be functions', () => { assert.strictEqual(typeof isDevelopment, 'function');