Compare commits
2 Commits
859db6ccb2
...
aa872bdd6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa872bdd6b | ||
|
|
5810008fa5 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@reliancy/bface",
|
"name": "@reliancy/bface",
|
||||||
"version": "1.0.8",
|
"version": "1.0.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
|
|||||||
+135
-13
@@ -7,7 +7,7 @@
|
|||||||
* added when needed.
|
* 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';
|
import { getDesktopBridgeSafe, getHostKind, isElectronHost } from './host.js';
|
||||||
|
|
||||||
// Config key for last visited route path
|
// Config key for last visited route path
|
||||||
@@ -640,6 +640,124 @@ export async function getHostInfo() {
|
|||||||
// URL/Path Management (Browser URL synchronization)
|
// 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)
|
* Get router path (current path the router should use)
|
||||||
* Checks browser URL first, then localStorage for last visited path, then falls back to default
|
* 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<string>} Router path to use
|
* @returns {Promise<string>} Router path to use
|
||||||
*/
|
*/
|
||||||
export async function getRouterPath(defaultPath = '/') {
|
export async function getRouterPath(defaultPath = '/') {
|
||||||
|
const routerBase = resolveActiveRouterBase();
|
||||||
|
|
||||||
// First, check browser URL (web only)
|
// First, check browser URL (web only)
|
||||||
if (typeof window !== 'undefined' && window.location) {
|
if (typeof window !== 'undefined' && window.location) {
|
||||||
const urlPath = window.location.pathname;
|
const internalPath = stripRouterBase(window.location.pathname, routerBase);
|
||||||
// If URL has a meaningful path (not just '/'), use it
|
if (internalPath && internalPath !== '/') {
|
||||||
if (urlPath && urlPath !== '/') {
|
return internalPath;
|
||||||
return urlPath;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,19 +807,21 @@ export async function getRouterPath(defaultPath = '/') {
|
|||||||
* Web: Uses history.pushState or history.replaceState
|
* Web: Uses history.pushState or history.replaceState
|
||||||
* React Native: Only saves to storage (no URL to update)
|
* 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
|
* @param {boolean} [replace=false] - Whether to replace current history entry
|
||||||
*/
|
*/
|
||||||
export async function setRouterPath(path, replace = false, options = {}) {
|
export async function setRouterPath(path, replace = false, options = {}) {
|
||||||
const { notify = true, state = null } = 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)
|
// Update browser URL (web only)
|
||||||
if (typeof window !== 'undefined' && window.history) {
|
if (typeof window !== 'undefined' && window.history) {
|
||||||
if (replace) {
|
if (replace) {
|
||||||
window.history.replaceState(state, '', fullPath);
|
window.history.replaceState(state, '', publicPath);
|
||||||
} else {
|
} else {
|
||||||
window.history.pushState(state, '', fullPath);
|
window.history.pushState(state, '', publicPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notify) {
|
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 {
|
try {
|
||||||
const scopedLastPathKey = await getScopedLastPathConfigKey();
|
const scopedLastPathKey = await getScopedLastPathConfigKey();
|
||||||
await setConfig(scopedLastPathKey, fullPath);
|
await setConfig(scopedLastPathKey, internalPath);
|
||||||
|
|
||||||
const appName = await getConfig(CONFIG_KEYS.APP_NAME, DEFAULT_APP_NAME);
|
const appName = await getConfig(CONFIG_KEYS.APP_NAME, DEFAULT_APP_NAME);
|
||||||
if (appName === DEFAULT_APP_NAME) {
|
if (appName === DEFAULT_APP_NAME) {
|
||||||
await setConfig(LAST_PATH_CONFIG_KEY, fullPath);
|
await setConfig(LAST_PATH_CONFIG_KEY, internalPath);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Compat] Failed to save path to config:', error);
|
console.warn('[Compat] Failed to save path to config:', error);
|
||||||
@@ -741,7 +862,8 @@ export function subscribeToPathChanges(listener) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePopState = (event) => {
|
const handlePopState = (event) => {
|
||||||
const path = window.location.pathname;
|
const routerBase = resolveActiveRouterBase();
|
||||||
|
const path = stripRouterBase(window.location.pathname, routerBase);
|
||||||
try {
|
try {
|
||||||
listener(path, event.state);
|
listener(path, event.state);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+85
-4
@@ -96,14 +96,20 @@ export const CONFIG_KEYS = {
|
|||||||
/** Development host: extra dev UI, SW dev behavior, etc. Layered via getConfig (storage → profile → bundler). */
|
/** Development host: extra dev UI, SW dev behavior, etc. Layered via getConfig (storage → profile → bundler). */
|
||||||
DEV_HOST: 'DEV_HOST',
|
DEV_HOST: 'DEV_HOST',
|
||||||
/** Whether the app should register and use a service worker. */
|
/** 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
|
// 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.API_BASE_URL,
|
||||||
CONFIG_KEYS.MODULES,
|
CONFIG_KEYS.MODULES,
|
||||||
CONFIG_KEYS.SECURITY_CONFIG
|
CONFIG_KEYS.SECURITY_CONFIG,
|
||||||
|
CONFIG_KEYS.APP_BASE,
|
||||||
|
CONFIG_KEYS.ROUTER_BASE
|
||||||
].forEach((key) => lockedKeys.add(key));
|
].forEach((key) => lockedKeys.add(key));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,6 +164,72 @@ export function resolveServiceWorkerEnabled(appConfig = {}) {
|
|||||||
return true;
|
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
|
* Initialize environment config
|
||||||
* @param {Object} appConfig - Configuration from profile
|
* @param {Object} appConfig - Configuration from profile
|
||||||
@@ -170,12 +242,15 @@ export function initEnv(appConfig) {
|
|||||||
|| api.base_url
|
|| api.base_url
|
||||||
|| api.baseURL
|
|| api.baseURL
|
||||||
|| '/api';
|
|| '/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 = {
|
config = {
|
||||||
APP_NAME: appConfig.id || appConfig.name,
|
APP_NAME: appConfig.id || appConfig.name,
|
||||||
APP_DISPLAY_NAME: appConfig.displayName || appConfig.short_name || appConfig.name,
|
APP_DISPLAY_NAME: appConfig.displayName || appConfig.short_name || appConfig.name,
|
||||||
APP_DESCRIPTION: appConfig.description || '',
|
APP_DESCRIPTION: appConfig.description || '',
|
||||||
FAVICON: appConfig.favicon || appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg',
|
FAVICON: prefixPublicAssetPath(faviconPath, app_base),
|
||||||
BRAND_LOGO: appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg',
|
BRAND_LOGO: prefixPublicAssetPath(brandLogoPath, app_base),
|
||||||
THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000',
|
THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000',
|
||||||
BACKGROUND_COLOR: appConfig.background_color || appConfig.backgroundColor || '#ffffff',
|
BACKGROUND_COLOR: appConfig.background_color || appConfig.backgroundColor || '#ffffff',
|
||||||
UI_SHELL: appConfig.ui_shell || appConfig.uiShell || 'EmptyShell',
|
UI_SHELL: appConfig.ui_shell || appConfig.uiShell || 'EmptyShell',
|
||||||
@@ -193,9 +268,15 @@ export function initEnv(appConfig) {
|
|||||||
LOCALE: appConfig.locale || null,
|
LOCALE: appConfig.locale || null,
|
||||||
[CONFIG_KEYS.DEV_HOST]: resolveDevHostFlag(appConfig),
|
[CONFIG_KEYS.DEV_HOST]: resolveDevHostFlag(appConfig),
|
||||||
[CONFIG_KEYS.SERVICE_WORKER_ENABLED]: resolveServiceWorkerEnabled(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
|
// Store full profile for advanced access
|
||||||
_profile: appConfig
|
_profile: appConfig
|
||||||
};
|
};
|
||||||
|
if (typeof globalThis !== 'undefined') {
|
||||||
|
globalThis.__BFACE_APP_BASE__ = app_base;
|
||||||
|
globalThis.__BFACE_ROUTER_BASE__ = router_base;
|
||||||
|
}
|
||||||
apiClient.setBaseURL(resolvedApiBaseURL);
|
apiClient.setBaseURL(resolvedApiBaseURL);
|
||||||
configureKeyValueStoreBackend(config.STORAGE_BACKEND);
|
configureKeyValueStoreBackend(config.STORAGE_BACKEND);
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-1
@@ -72,10 +72,55 @@ export const InvokeHandlers = {
|
|||||||
* @param {Event|null} event - Original event object
|
* @param {Event|null} event - Original event object
|
||||||
*/
|
*/
|
||||||
goToModal: (menuItem, eventSource = null, event = null) => {
|
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
|
* Subscribe to menu changes
|
||||||
* @param {Function} listener - Callback function called when menu changes
|
* @param {Function} listener - Callback function called when menu changes
|
||||||
|
|||||||
+39
-6
@@ -7,14 +7,14 @@
|
|||||||
import { isElectronHost, isTauriHost } from './host.js';
|
import { isElectronHost, isTauriHost } from './host.js';
|
||||||
import {
|
import {
|
||||||
getConfig,
|
getConfig,
|
||||||
|
getConfigSync,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
isServiceWorkerEnabled,
|
isServiceWorkerEnabled,
|
||||||
isServiceWorkerEnabledSync,
|
isServiceWorkerEnabledSync,
|
||||||
|
normalizeRouterBase,
|
||||||
CONFIG_KEYS
|
CONFIG_KEYS
|
||||||
} from './env.js';
|
} from './env.js';
|
||||||
|
|
||||||
const SW_PATH = '/sw.js';
|
|
||||||
const SW_SCOPE = '/';
|
|
||||||
const DEV_SW_RESET_KEY = '__bface_dev_sw_reset__';
|
const DEV_SW_RESET_KEY = '__bface_dev_sw_reset__';
|
||||||
|
|
||||||
/** Bit flags for {@link clearPWACache}. Combine with `|`. */
|
/** Bit flags for {@link clearPWACache}. Combine with `|`. */
|
||||||
@@ -243,9 +243,10 @@ async function executeServiceWorkerPolicy(policy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let registration = await resolveServiceWorkerRegistration();
|
let registration = await resolveServiceWorkerRegistration();
|
||||||
|
const { scriptURL, scope } = resolveServiceWorkerMount();
|
||||||
if (!registration) {
|
if (!registration) {
|
||||||
registration = await navigator.serviceWorker.register(SW_PATH, {
|
registration = await navigator.serviceWorker.register(scriptURL, {
|
||||||
scope: SW_SCOPE,
|
scope,
|
||||||
updateViaCache: 'none'
|
updateViaCache: 'none'
|
||||||
});
|
});
|
||||||
console.log('Service Worker registered:', registration);
|
console.log('Service Worker registered:', registration);
|
||||||
@@ -312,14 +313,46 @@ async function resetServiceWorkers() {
|
|||||||
return true;
|
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() {
|
async function resolveServiceWorkerRegistration() {
|
||||||
if (!hasServiceWorkerSupport()) {
|
if (!hasServiceWorkerSupport()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { scope } = resolveServiceWorkerMount();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await navigator.serviceWorker.getRegistration(SW_SCOPE)
|
const scopedRegistration = await navigator.serviceWorker.getRegistration(scope);
|
||||||
|| await navigator.serviceWorker.getRegistration();
|
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) {
|
} catch (error) {
|
||||||
console.warn('[SW] Failed to read service worker registration:', error);
|
console.warn('[SW] Failed to read service worker registration:', error);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import React, { Suspense, createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import React, { Suspense, createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Spinner, YStack } from 'tamagui';
|
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 { openExternalURL, getRouterPath, setRouterPath, subscribeToPathChanges } from '../../platform/compat.js';
|
||||||
import { ErrorPage } from '../../security/pages/ErrorPage.jsx';
|
import { ErrorPage } from '../../security/pages/ErrorPage.jsx';
|
||||||
import { evaluateRouteAccess } from '../../security/runtime/route-guards.js';
|
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) => {
|
InvokeHandlers.goToModal = (menuItem, eventSource, event) => {
|
||||||
if (menuItem.invoke_target) {
|
routerDebug('[Router] Opening modal:', menuItem.invoke_target);
|
||||||
routerDebug('[Router] Modal not yet implemented:', menuItem.invoke_target);
|
return invokeRegisteredModal(menuItem, eventSource, event);
|
||||||
// TODO: Implement modal system
|
|
||||||
} else {
|
|
||||||
console.warn('[Router] MenuItem missing invoke_target for goToModal');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
routerDebug('[Router] InvokeHandlers configured');
|
routerDebug('[Router] InvokeHandlers configured');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import RecordsModel from '../../data/RecordsModel.js';
|
|||||||
import { getIcon } from './IconMapper.jsx';
|
import { getIcon } from './IconMapper.jsx';
|
||||||
import { SidePanelShell } from './SidePanelShell.jsx';
|
import { SidePanelShell } from './SidePanelShell.jsx';
|
||||||
import { networkActivityManager } from '../../platform/api.js';
|
import { networkActivityManager } from '../../platform/api.js';
|
||||||
|
import { registerModalHandler } from '../../platform/menu.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Shell Context
|
// Shell Context
|
||||||
@@ -572,6 +573,10 @@ class NotificationCenterManager {
|
|||||||
|
|
||||||
const notificationCenterManager = new NotificationCenterManager();
|
const notificationCenterManager = new NotificationCenterManager();
|
||||||
|
|
||||||
|
registerModalHandler('notifications', () => {
|
||||||
|
notificationCenterManager.open();
|
||||||
|
});
|
||||||
|
|
||||||
function getNotificationTypeStyle(type = 'info') {
|
function getNotificationTypeStyle(type = 'info') {
|
||||||
return {
|
return {
|
||||||
info: {
|
info: {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
isProduction,
|
isProduction,
|
||||||
isServiceWorkerEnabledSync,
|
isServiceWorkerEnabledSync,
|
||||||
resolveServiceWorkerEnabled,
|
resolveServiceWorkerEnabled,
|
||||||
|
resolveProfileBases,
|
||||||
|
normalizeRouterBase,
|
||||||
CONFIG_KEYS
|
CONFIG_KEYS
|
||||||
} from '../src/platform/env.js';
|
} from '../src/platform/env.js';
|
||||||
import { getProvider } from '../src/platform/storage.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', () => {
|
describe('isDevelopment and isProduction', () => {
|
||||||
test('should be functions', () => {
|
test('should be functions', () => {
|
||||||
assert.strictEqual(typeof isDevelopment, 'function');
|
assert.strictEqual(typeof isDevelopment, 'function');
|
||||||
|
|||||||
+9
-1
@@ -33,7 +33,15 @@ export default defineConfig({
|
|||||||
index: path.resolve(__dirname, 'src/index.js'),
|
index: path.resolve(__dirname, 'src/index.js'),
|
||||||
'ui/components/index': path.resolve(__dirname, 'src/ui/components/index.js'),
|
'ui/components/index': path.resolve(__dirname, 'src/ui/components/index.js'),
|
||||||
'ui/route-loading': path.resolve(__dirname, 'src/ui/route-loading.js'),
|
'ui/route-loading': path.resolve(__dirname, 'src/ui/route-loading.js'),
|
||||||
'ui/pages/SettingsPage': path.resolve(__dirname, 'src/ui/pages/SettingsPage.jsx')
|
'ui/pages/SettingsPage': path.resolve(__dirname, 'src/ui/pages/SettingsPage.jsx'),
|
||||||
|
// Barrel entries: these are pure re-export modules that internal code never
|
||||||
|
// imports (it imports the concrete files directly), so under preserveModules
|
||||||
|
// they are otherwise omitted from dist (only .d.ts emitted), breaking consumer
|
||||||
|
// imports like `@reliancy/bface/security/model/index.js`.
|
||||||
|
'security/index': path.resolve(__dirname, 'src/security/index.js'),
|
||||||
|
'security/model/index': path.resolve(__dirname, 'src/security/model/index.js'),
|
||||||
|
'security/policy/index': path.resolve(__dirname, 'src/security/policy/index.js'),
|
||||||
|
'security/pages/index': path.resolve(__dirname, 'src/security/pages/index.js')
|
||||||
},
|
},
|
||||||
formats: ['es']
|
formats: ['es']
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user