diff --git a/src/platform/sw-register.js b/src/platform/sw-register.js index f297880..3351d92 100644 --- a/src/platform/sw-register.js +++ b/src/platform/sw-register.js @@ -2,13 +2,12 @@ * Service Worker Registration */ -import { isElectronHost } from './host.js'; +import { isElectronHost, isTauriHost } from './host.js'; import { getConfig, isDevelopment, CONFIG_KEYS } from './env.js'; const SW_PATH = '/sw.js'; const SW_SCOPE = '/'; const DEV_SW_RESET_KEY = '__bface_dev_sw_reset__'; - /** * Clear all caches */ @@ -127,8 +126,9 @@ export async function clearPWACache() { * Register service worker */ export async function registerServiceWorker() { - if (isElectronHost()) { - console.log('[SW] Skipping service worker registration in Electron host'); + if (isElectronHost() || isTauriHost()) { + await unregisterAllServiceWorkers(); + console.log('[SW] Skipping service worker registration in desktop host'); return null; } @@ -185,8 +185,9 @@ export async function registerServiceWorker() { * In development, stale service workers can break Vite module loading by * intercepting /@fs and related requests. Clear them once and reload cleanly. */ -export async function ensureDevelopmentServiceWorkerState() { - if (isElectronHost()) { +export async function resetServiceWorkers() { + if (isElectronHost() || isTauriHost()) { + await unregisterAllServiceWorkers(); return false; } @@ -214,11 +215,39 @@ export async function ensureDevelopmentServiceWorkerState() { return true; } +export async function getServiceWorkerStatus() { + if (isElectronHost() || isTauriHost()) { + return 'Desktop Disabled'; + } + + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return 'Not Supported'; + } + + const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment()); + try { + if (devHost) { + const registration = await navigator.serviceWorker.getRegistration(); + if (registration) { + registration.update(); + return 'Active'; + } + return 'Development Disabled'; + } + + await navigator.serviceWorker.ready; + return 'Active'; + } catch (error) { + console.warn('[SW] Failed to resolve service worker status:', error); + return 'Not Supported'; + } +} + /** * Unregister service worker */ export async function unregisterServiceWorker() { - if (isElectronHost()) { + if (isElectronHost() || isTauriHost()) { return; } diff --git a/src/security/pages/SecurityAdminPage.jsx b/src/security/pages/SecurityAdminPage.jsx index 67051d2..8075b49 100644 --- a/src/security/pages/SecurityAdminPage.jsx +++ b/src/security/pages/SecurityAdminPage.jsx @@ -122,7 +122,7 @@ export function SecurityAdminPage() { ), 'No permits registered.') - } + }, ]; return ( diff --git a/src/ui/App.jsx b/src/ui/App.jsx index 1dff613..b402e4a 100644 --- a/src/ui/App.jsx +++ b/src/ui/App.jsx @@ -6,7 +6,7 @@ import React, { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { TamaguiProvider, Theme, createTamagui, YStack } from 'tamagui'; -import { registerServiceWorker, clearPWACache } from '../platform/sw-register.js'; +import { clearPWACache, getServiceWorkerStatus } from '../platform/sw-register.js'; import { getProvider } from '../platform/storage.js'; import * as apiClient from '../platform/api.js'; import * as storageModuleRef from '../platform/storage.js'; @@ -68,7 +68,15 @@ function getCachedTamaguiConfig(styleThemeName) { // App Component // ============================================================================ -function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, initialProfile = null }) { +function App({ + onInit, + renderBootScreen = null, + showInitialBootSplash = false, + initialProfile = null, + initialThemeMode = THEME_MODES.SYSTEM, + initialStyleThemeName = DEFAULT_STYLE_THEME, + initialThemePreferencesLoaded = false +}) { // App state const [swStatus, setSwStatus] = useState('Checking...'); const [storageBackend, setStorageBackend] = useState('localStorage'); @@ -80,10 +88,10 @@ function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, i const [bootModeOverride, setBootModeOverride] = useState(null); // Theme state - const [themeMode, setThemeModeState] = useState(THEME_MODES.SYSTEM); + const [themeMode, setThemeModeState] = useState(initialThemeMode); const [systemScheme, setSystemScheme] = useState(getSystemThemeMode()); - const [styleThemeName, setStyleThemeName] = useState(DEFAULT_STYLE_THEME); - const [themePreferencesLoaded, setThemePreferencesLoaded] = useState(false); + const [styleThemeName, setStyleThemeName] = useState(() => normalizeStyleThemeName(initialStyleThemeName)); + const [themePreferencesLoaded, setThemePreferencesLoaded] = useState(initialThemePreferencesLoaded); const securityState = useSecurityState(); // Initialize theme manager @@ -111,6 +119,10 @@ function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, i // Load theme preferences from storage on mount useEffect(() => { + if (initialThemePreferencesLoaded) { + return; + } + async function loadThemePreferences() { try { // Load theme mode (light/dark/system) @@ -132,7 +144,7 @@ function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, i } loadThemePreferences(); - }, []); + }, [initialThemePreferencesLoaded]); // Listen for system theme changes using platform-agnostic compat layer useEffect(() => { @@ -285,26 +297,7 @@ function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, i appLogger.warn('Menu service not available'); } - // Update service worker status if available - const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment()); - if ('serviceWorker' in navigator) { - try { - if (devHost) { - const registration = await navigator.serviceWorker.getRegistration(); - setSwStatus(registration ? 'Active' : 'Development Disabled'); - if (registration) { - registration.update(); - } - } else { - const registration = await navigator.serviceWorker.ready; - setSwStatus('Active'); - } - } catch (error) { - setSwStatus('Not Supported'); - } - } else { - setSwStatus('Not Supported'); - } + setSwStatus(await getServiceWorkerStatus()); setInitialized(true); initTrace.end({ diff --git a/src/ui/components/DashboardShell.jsx b/src/ui/components/DashboardShell.jsx index b1b7d8e..fbcdd51 100644 --- a/src/ui/components/DashboardShell.jsx +++ b/src/ui/components/DashboardShell.jsx @@ -15,12 +15,14 @@ import { SideBar } from './SideBar.jsx'; * @param {React.ReactNode} props.children - Content to render inside shell */ export function DashboardShell(props) { + const { secondaryStyle = 'inline', ...shellProps } = props; + return ( - - + + {/* Menu items will be placed here via placement */} - {props.children} + {shellProps.children} ); } diff --git a/src/ui/components/Router.jsx b/src/ui/components/Router.jsx index 7a8af1a..8f2f0b4 100644 --- a/src/ui/components/Router.jsx +++ b/src/ui/components/Router.jsx @@ -28,16 +28,26 @@ function getComponentLabel(component) { * Route registry for module-declared routes * Modules can publish routes here during initialization, and Router will collect them on mount */ -const routeRegistry = []; -let registryModified = false; // Flag to indicate routes have been published -const routeRegistryListeners = new Set(); +const ROUTE_REGISTRY_KEY = '__bface_route_registry__'; + +function getRouteRegistryState() { + const scope = typeof globalThis !== 'undefined' ? globalThis : window; + if (!scope[ROUTE_REGISTRY_KEY]) { + scope[ROUTE_REGISTRY_KEY] = { + routes: [], + registryModified: false, + listeners: new Set() + }; + } + return scope[ROUTE_REGISTRY_KEY]; +} /** * Get all published routes from registry (called by Router on mount) * @returns {Array} Array of route definitions */ function getPublishedRoutes() { - return routeRegistry; // Return registry directly + return getRouteRegistryState().routes; } /** @@ -45,13 +55,14 @@ function getPublishedRoutes() { * @returns {boolean} True if registry was modified since last check */ function getAndClearRegistryModified() { - const wasModified = registryModified; - registryModified = false; // Clear the flag + const state = getRouteRegistryState(); + const wasModified = state.registryModified; + state.registryModified = false; return wasModified; } function notifyRouteRegistryListeners() { - for (const listener of routeRegistryListeners) { + for (const listener of getRouteRegistryState().listeners) { try { listener(); } catch (error) { @@ -61,9 +72,10 @@ function notifyRouteRegistryListeners() { } function subscribeToRouteRegistry(listener) { - routeRegistryListeners.add(listener); + const state = getRouteRegistryState(); + state.listeners.add(listener); return () => { - routeRegistryListeners.delete(listener); + state.listeners.delete(listener); }; } @@ -71,8 +83,9 @@ function subscribeToRouteRegistry(listener) { * Clear the route registry (for testing/cleanup) */ export function clearRouteRegistry() { - routeRegistry.length = 0; - registryModified = false; + const state = getRouteRegistryState(); + state.routes.length = 0; + state.registryModified = false; notifyRouteRegistryListeners(); } @@ -261,10 +274,11 @@ export function publishRoutes(input, options = {}) { } // Add to registry (Router will pick up on mount) - routeRegistry.push(...normalizedRoutes); - registryModified = true; // Set flag to indicate routes have been published + const state = getRouteRegistryState(); + state.routes.push(...normalizedRoutes); + state.registryModified = true; notifyRouteRegistryListeners(); - console.log(`[Router] Published ${normalizedRoutes.length} route(s) to registry. Total: ${routeRegistry.length}`); + console.log(`[Router] Published ${normalizedRoutes.length} route(s) to registry. Total: ${state.routes.length}`); } // ============================================================================ @@ -275,6 +289,45 @@ const RouterContext = createContext(null); const EMPTY_ROUTE = { path: '', component: null, params: {}, state: null, is_fragment: false, fragment_path: null, options: {} }; const RouteContext = createContext(EMPTY_ROUTE); +class RouteErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error) { + return { error }; + } + + componentDidCatch(error) { + console.error('[Router.SelectedComponent] Route component failed to render:', error); + } + + componentDidUpdate(prevProps) { + if (prevProps.resetKey !== this.props.resetKey && this.state.error) { + this.setState({ error: null }); + } + } + + render() { + const { error } = this.state; + const { children, routePath, debug } = this.props; + if (error) { + return ( + + ); + } + + return children; + } +} + /** * useRouter Hook * Access router instance and navigation methods @@ -434,9 +487,15 @@ function SelectedComponent({ placement, fallback }) { console.log('[Router.SelectedComponent] Rendering component:', getComponentLabel(RouteComponent), route.fragment_path ? `(fragment path: ${route.fragment_path})` : ''); } return ( - - - + + + + + ); } diff --git a/src/ui/components/SideBar.jsx b/src/ui/components/SideBar.jsx index 3c5c545..078f55d 100644 --- a/src/ui/components/SideBar.jsx +++ b/src/ui/components/SideBar.jsx @@ -112,41 +112,6 @@ function useSideBarContent(children) { } }); - // Add secondary menu items to bottomSide - secondaryMenuItems.forEach((item) => { - // Validate that item is a MenuItem instance - if (!(item instanceof MenuItem)) { - console.error('[SideBar] Expected MenuItem instance but got:', { - type: typeof item, - constructor: item?.constructor?.name, - item: item, - stack: new Error().stack - }); - return; // Skip invalid items - } - sections.bottomSide.push( - - ); - }); - - // Add personal menu item as last element in bottomSide - if (personalRoot && personalRoot instanceof MenuItem) { - sections.bottomSide.push( - - ); - } - return { sections, primaryMenuItems, @@ -167,7 +132,8 @@ function SideBarWide({ topSideHeight = 0, bottomSideHeight = 0, expandedWidth = 250, - collapsedWidth = 80 + collapsedWidth = 80, + secondaryStyle = 'inline' }) { const [brandLogo, setBrandLogo] = useState(null); const [appName, setAppName] = useState(null); @@ -222,12 +188,30 @@ function SideBarWide({ }; const currentWidth = isCollapsed ? collapsedWidth : expandedWidth; + const canFitInlineSecondary = (() => { + const itemCount = organizedChildren.secondaryMenuItems.length; + if (itemCount <= 0) { + return true; + } + + // Account for the sidebar's horizontal padding and a compact icon-only row. + const availableWidth = Math.max(currentWidth - 16, 0); + const estimatedButtonWidth = 24; + const estimatedGapWidth = 4; + const requiredWidth = (itemCount * estimatedButtonWidth) + ((itemCount - 1) * estimatedGapWidth); + + return requiredWidth <= availableWidth; + })(); + const resolvedSecondaryStyle = secondaryStyle === 'inline' && canFitInlineSecondary ? 'inline' : 'stacked'; + const hasBottomContent = organizedChildren.sections.bottomSide.length > 0 + || organizedChildren.secondaryMenuItems.length > 0 + || (organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem); return ( @@ -316,21 +300,54 @@ function SideBarWide({ {/* Bottom Side - Secondary and Personal menu items (always shown if has content) */} - {organizedChildren.sections.bottomSide.length > 0 && ( + {hasBottomContent && ( - {React.Children.map(organizedChildren.sections.bottomSide, (child) => { - if (React.isValidElement(child) && (child.type === MenuItemButton || child.type === PersonalMenuItem)) { - return React.cloneElement(child, { collapsed: isCollapsed }); - } - return child; - })} + {organizedChildren.sections.bottomSide} + + {organizedChildren.secondaryMenuItems.length > 0 && resolvedSecondaryStyle === 'inline' ? ( + + {organizedChildren.secondaryMenuItems.map((item) => ( + + + + ))} + + ) : ( + organizedChildren.secondaryMenuItems.map((item) => ( + + )) + )} + + {organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && ( + + )} )} @@ -474,6 +491,7 @@ function SideBarNarrow({ children }) { * @param {React.ReactNode} props.children - Content to render (defaults to topSide) * @param {number} [props.topSideHeight=0] - Top side height (default: 0, wide only) * @param {number} [props.bottomSideHeight=0] - Bottom side height (default: 0, wide only) + * @param {string} [props.secondaryStyle='inline'] - Secondary menu rendering: 'stacked' | 'inline' (wide only) */ export function SideBar(props) { const media = useMedia(); diff --git a/src/ui/pages/SettingsPage.jsx b/src/ui/pages/SettingsPage.jsx index 8c15981..d598818 100644 --- a/src/ui/pages/SettingsPage.jsx +++ b/src/ui/pages/SettingsPage.jsx @@ -1,7 +1,7 @@ /** * SettingsPage - Settings page component - * Derived from Page component - * Displays all settings fragment routes as panels + * Platform default: lists registered fragment routes under the settings base path + * and mounts each with {@code settingsFragmentPath} so fragments can resolve their own meta. */ import React, { useMemo } from 'react'; @@ -11,51 +11,21 @@ import { useRouter, useRoute } from '../components/Router.jsx'; import { getRootItem } from '../../platform/menu.js'; import { securityService, useSecurityState } from '../../security/runtime/security-service.js'; -/** - * SettingsPage Component - * Settings page with icon and title - * Queries Router for all routes under /settings and renders them as panels - */ export function SettingsPage() { const router = useRouter(); const route = useRoute(); const securityState = useSecurityState(); - - // Initialize search query from fragment path if we navigated via a fragment route - const initialSearchQuery = useMemo(() => { - if (route.fragment_path) { - // Extract the last segment from fragment path (e.g., "general" from "/settings/general") - const segments = route.fragment_path.split('/').filter(s => s.length > 0); - if (segments.length > 1) { - return segments[segments.length - 1]; // Return last segment - } - } - return ''; // No fragment, show all - }, [route.fragment_path]); - - const [searchQuery, setSearchQuery] = React.useState(initialSearchQuery); - - // Update search query when fragment path changes (e.g., navigating between fragments) - React.useEffect(() => { - setSearchQuery(initialSearchQuery); - }, [initialSearchQuery]); - - // Get the base path for this settings page (flexible - could be /settings, /config, etc.) + const [searchQuery, setSearchQuery] = React.useState(''); + const basePath = useMemo(() => { - // Get current route path and extract the base (e.g., /settings from /settings/general) if (route.path) { - // If it's a fragment route, use fragment_path, otherwise use path const currentPath = route.fragment_path || route.path; - // Extract base path (first segment) - const segments = currentPath.split('/').filter(s => s.length > 0); + const segments = currentPath.split('/').filter((s) => s.length > 0); return segments.length > 0 ? `/${segments[0]}` : '/settings'; } - return '/settings'; // Default fallback + return '/settings'; }, [route.path, route.fragment_path]); - - // Query Router for all routes under the base path - // Depend on router.currentRoute to trigger recalculation when routes are registered - // (currentRoute depends on routesVersion which changes when routes are registered) + const childRoutes = useMemo(() => { const allRoutes = router.getRoutes(); const settingsRoot = getRootItem('settings'); @@ -65,11 +35,9 @@ export function SettingsPage() { isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options) }; const routes = []; - - // Find all routes that start with basePath but are not the basePath itself + for (const [path, routeData] of allRoutes.entries()) { - if (path.startsWith(basePath + '/') && path !== basePath) { - // Only include fragment routes + if (path.startsWith(`${basePath}/`) && path !== basePath) { if (routeData.is_fragment) { const matchingMenuItem = settingsItems.find((item) => item.invoke_target === path); if (matchingMenuItem && !matchingMenuItem.isRenderable(security)) { @@ -83,56 +51,42 @@ export function SettingsPage() { } } } - - // Sort by path for consistent ordering + routes.sort((a, b) => a.path.localeCompare(b.path)); - return routes; - }, [router, basePath, router.currentRoute, securityState]); // Include router.currentRoute to trigger when routes are registered - - // Filter routes based on search query + }, [router, basePath, router.currentRoute, securityState]); + const filteredRoutes = useMemo(() => { if (!searchQuery.trim()) { return childRoutes; } - const query = searchQuery.toLowerCase(); - return childRoutes.filter(route => { - // Extract the last segment of the path as the route name - const segments = route.path.split('/').filter(s => s.length > 0); + return childRoutes.filter((r) => { + const segments = r.path.split('/').filter((s) => s.length > 0); const routeName = segments[segments.length - 1] || ''; return routeName.toLowerCase().includes(query); }); }, [childRoutes, searchQuery]); - + return ( - + - {/* Search bar */} - - {/* Render all child route components */} {filteredRoutes.length > 0 ? ( filteredRoutes.map((routeItem) => { const RouteComponent = routeItem.component; return ( - + ); }) ) : ( - - {searchQuery ? 'No settings found matching your search.' : 'No settings available.'} + + {searchQuery.trim() ? 'No settings found matching your search.' : 'No settings available.'} )}