Refine shared shell, router, and service worker behavior

This commit is contained in:
Amer Agovic
2026-05-09 06:46:30 -05:00
parent 2157e1aea6
commit ff515fddf6
7 changed files with 221 additions and 166 deletions
+36 -7
View File
@@ -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;
}
+1 -1
View File
@@ -122,7 +122,7 @@ export function SecurityAdminPage() {
</Paragraph>
</YStack>
), 'No permits registered.')
}
},
];
return (
+19 -26
View File
@@ -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({
+5 -3
View File
@@ -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 (
<EmptyShell {...props} initialLeftWidth={250}>
<SideBar placement="leftSide">
<EmptyShell {...shellProps} initialLeftWidth={250}>
<SideBar placement="leftSide" secondaryStyle={secondaryStyle}>
{/* Menu items will be placed here via placement */}
</SideBar>
{props.children}
{shellProps.children}
</EmptyShell>
);
}
+76 -17
View File
@@ -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 (
<ErrorPage
title="Page Failed To Load"
icon="error"
message={`The route "${routePath || '/'}" could not be rendered.`}
error={error}
debug={debug}
/>
);
}
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 (
<Suspense fallback={null}>
<RouteComponent />
</Suspense>
<RouteErrorBoundary
resetKey={routeSignature}
routePath={route.path}
debug={true}
>
<Suspense fallback={null}>
<RouteComponent />
</Suspense>
</RouteErrorBoundary>
);
}
+64 -46
View File
@@ -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(
<MenuItemButton
key={item.id || item.path}
menuItem={item}
orientation="vertical"
stateVersion={menuVersion}
/>
);
});
// Add personal menu item as last element in bottomSide
if (personalRoot && personalRoot instanceof MenuItem) {
sections.bottomSide.push(
<PersonalMenuItem
key="personal-menu"
personalRoot={personalRoot}
orientation="vertical"
expand_mode="popup"
stateVersion={menuVersion}
/>
);
}
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 (
<YStack
width={currentWidth}
height="100%"
gap="$2"
gap="$1"
padding="$2"
backgroundColor="$bgPanel"
borderRightWidth={1}
@@ -252,7 +236,7 @@ function SideBarWide({
<XStack
width="100%"
alignItems="center"
gap="$2"
gap="$1"
paddingVertical="$2"
style={{ flexShrink: 0 }}
>
@@ -316,21 +300,54 @@ function SideBarWide({
</YStack>
{/* Bottom Side - Secondary and Personal menu items (always shown if has content) */}
{organizedChildren.sections.bottomSide.length > 0 && (
{hasBottomContent && (
<YStack
width="100%"
gap="$2"
gap="$1"
alignItems="flex-start"
justifyContent="flex-end"
paddingTop="$2"
style={{ flexShrink: 0 }}
>
{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' ? (
<XStack width="100%" alignItems="center" justifyContent="space-between" gap="$1" flexWrap="nowrap">
{organizedChildren.secondaryMenuItems.map((item) => (
<XStack key={item.id || item.path} flex={1} justifyContent="center" minWidth={0}>
<MenuItemButton
menuItem={item}
orientation="horizontal"
displayStyle="icon_only"
width="100%"
padding="$1"
stateVersion={organizedChildren.menuVersion}
/>
</XStack>
))}
</XStack>
) : (
organizedChildren.secondaryMenuItems.map((item) => (
<MenuItemButton
key={item.id || item.path}
menuItem={item}
orientation="vertical"
collapsed={isCollapsed}
stateVersion={organizedChildren.menuVersion}
/>
))
)}
{organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && (
<PersonalMenuItem
key="personal-menu"
personalRoot={organizedChildren.personalRoot}
orientation="vertical"
expand_mode="popup"
collapsed={isCollapsed}
stateVersion={organizedChildren.menuVersion}
/>
)}
</YStack>
)}
</YStack>
@@ -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();
+14 -60
View File
@@ -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();
const [searchQuery, setSearchQuery] = React.useState('');
// 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 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');
@@ -66,10 +36,8 @@ export function SettingsPage() {
};
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)) {
@@ -84,55 +52,41 @@ 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
}, [router, basePath, router.currentRoute, securityState]);
// Filter routes based on search query
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 (
<Page
icon="settings"
title="Settings"
headerRight={[
// Add buttons or controls here later
]}
>
<Page icon="settings" title="Settings" headerRight={[]}>
<YStack gap="$4" width="100%">
{/* Search bar */}
<Input
placeholder="Search settings..."
placeholder="Search settings"
value={searchQuery}
onChangeText={setSearchQuery}
size="$4"
/>
{/* Render all child route components */}
{filteredRoutes.length > 0 ? (
filteredRoutes.map((routeItem) => {
const RouteComponent = routeItem.component;
return (
<RouteComponent key={routeItem.path} />
<RouteComponent key={routeItem.path} settingsFragmentPath={routeItem.path} />
);
})
) : (
<Text fontSize="$4" color="$color" opacity={0.6}>
{searchQuery ? 'No settings found matching your search.' : 'No settings available.'}
<Text fontSize="$4" color="$textMuted">
{searchQuery.trim() ? 'No settings found matching your search.' : 'No settings available.'}
</Text>
)}
</YStack>