UNPKG

@zubridge/electron

Version:

A streamlined state management library for Electron applications using Zustand.

645 lines (632 loc) 22.7 kB
'use strict'; var electron = require('electron'); /** * Constants used for IPC communication between main and renderer processes. * These are internal to the Zubridge electron implementation. */ var IpcChannel; (function (IpcChannel) { /** Channel for subscribing to state updates */ IpcChannel["SUBSCRIBE"] = "__zubridge_state_update"; /** Channel for getting the current state */ IpcChannel["GET_STATE"] = "__zubridge_get_initial_state"; /** Channel for dispatching actions */ IpcChannel["DISPATCH"] = "__zubridge_dispatch_action"; /** Channel for acknowledging action dispatches */ IpcChannel["DISPATCH_ACK"] = "__zubridge_dispatch_ack"; })(IpcChannel || (IpcChannel = {})); /** * Helper function to find a case-insensitive match in an object */ function findCaseInsensitiveMatch(obj, key) { // Try exact match first if (key in obj) { return [key, obj[key]]; } // Try case-insensitive match const keyLower = key.toLowerCase(); const matchingKey = Object.keys(obj).find((k) => k.toLowerCase() === keyLower); if (matchingKey) { return [matchingKey, obj[matchingKey]]; } return undefined; } /** * Helper function to find a handler by nested path * Example: "counter.increment" -> obj.counter.increment */ function findNestedHandler(obj, path) { try { const parts = path.split('.'); let current = obj; // Navigate through each part of the path for (let i = 0; i < parts.length; i++) { const part = parts[i]; // Case-insensitive comparison for each level const keys = Object.keys(current); const matchingKey = keys.find((k) => k.toLowerCase() === part.toLowerCase()); if (matchingKey === undefined) { return undefined; } current = current[matchingKey]; } return typeof current === 'function' ? current : undefined; } catch (error) { console.error('Error resolving nested handler:', error); return undefined; } } /** * Resolves a handler function from provided handlers using action type * This handles both direct matches and nested path resolution */ function resolveHandler(handlers, actionType) { // Try direct match with handlers const handlerMatch = findCaseInsensitiveMatch(handlers, actionType); if (handlerMatch && typeof handlerMatch[1] === 'function') { return handlerMatch[1]; } // Try nested path resolution in handlers return findNestedHandler(handlers, actionType); } /** * Creates a state manager adapter for Zustand stores */ function createZustandAdapter(store, options) { return { getState: () => store.getState(), subscribe: (listener) => store.subscribe(listener), processAction: (action) => { try { // First check if we have a custom handler for this action type if (options?.handlers) { // Try to resolve a handler for this action type const handler = resolveHandler(options.handlers, action.type); if (handler) { handler(action.payload); return; } } // Next check if we have a reducer if (options?.reducer) { store.setState(options.reducer(store.getState(), action)); return; } // Handle built-in actions if (action.type === 'setState') { store.setState(action.payload); } else { // Check for a matching method in the store state const state = store.getState(); // Try direct match with state functions const methodMatch = findCaseInsensitiveMatch(Object.fromEntries(Object.entries(state).filter(([_, value]) => typeof value === 'function')), action.type); if (methodMatch && typeof methodMatch[1] === 'function') { methodMatch[1](action.payload); return; } // Try nested path resolution in state const nestedStateHandler = findNestedHandler(state, action.type); if (nestedStateHandler) { nestedStateHandler(action.payload); return; } } } catch (error) { console.error('Error processing action:', error); } }, }; } /** * Creates a state manager adapter for Redux stores * * This adapter connects a Redux store to the Zubridge bridge, * allowing it to be used with the Electron IPC system. */ function createReduxAdapter(store, options) { return { getState: () => store.getState(), subscribe: (listener) => store.subscribe(() => listener(store.getState())), processAction: (action) => { try { // First check if we have a custom handler for this action type if (options?.handlers) { // Try to resolve a handler for this action type const handler = resolveHandler(options.handlers, action.type); if (handler) { handler(action.payload); return; } } // For Redux, we dispatch all actions to the store // with our standard Action format store.dispatch(action); } catch (error) { console.error('Error processing Redux action:', error); } }, }; } // WeakMap allows stores to be garbage collected when no longer referenced // Use a variable reference so we can replace it in tests let stateManagerRegistry = new WeakMap(); /** * Gets a state manager for the given store, creating one if it doesn't exist * @internal This is used by createDispatch and createCoreBridge */ function getStateManager(store, options) { // Check if we already have a state manager for this store if (stateManagerRegistry.has(store)) { return stateManagerRegistry.get(store); } // Create a new state manager based on store type let stateManager; if ('setState' in store) { // It's a Zustand store stateManager = createZustandAdapter(store, options); } else if ('dispatch' in store) { // It's a Redux store stateManager = createReduxAdapter(store, options); } else { throw new Error('Unrecognized store type. Must be a Zustand StoreApi or Redux Store.'); } // Cache the state manager stateManagerRegistry.set(store, stateManager); return stateManager; } /** * Removes a state manager from the registry * Useful when cleaning up to prevent memory leaks in long-running applications */ function removeStateManager(store) { stateManagerRegistry.delete(store); } /** * Type guard to check if an object is an Electron WebContents */ const isWebContents = (wrapperOrWebContents) => { return wrapperOrWebContents && typeof wrapperOrWebContents === 'object' && 'id' in wrapperOrWebContents; }; /** * Type guard to check if an object is a WebContentsWrapper */ const isWrapper = (wrapperOrWebContents) => { return wrapperOrWebContents && typeof wrapperOrWebContents === 'object' && 'webContents' in wrapperOrWebContents; }; /** * Get the WebContents object from either a WebContentsWrapper or WebContents */ const getWebContents = (wrapperOrWebContents) => { if (wrapperOrWebContents && typeof wrapperOrWebContents === 'object') { if ('id' in wrapperOrWebContents) { `WebContents ID: ${wrapperOrWebContents.id}`; } else if ('webContents' in wrapperOrWebContents) { `Wrapper with WebContents ID: ${wrapperOrWebContents.webContents?.id}`; } else ; } if (isWebContents(wrapperOrWebContents)) { return wrapperOrWebContents; } if (isWrapper(wrapperOrWebContents)) { const webContents = wrapperOrWebContents.webContents; return webContents; } return undefined; }; /** * Check if a WebContents is destroyed */ const isDestroyed = (webContents) => { try { if (typeof webContents.isDestroyed === 'function') { const destroyed = webContents.isDestroyed(); return destroyed; } return false; } catch (error) { return true; } }; /** * Safely send a message to a WebContents */ const safelySendToWindow = (webContents, channel, data) => { try { if (!webContents || isDestroyed(webContents)) { return false; } // Type check for WebContents API const hasWebContentsAPI = typeof webContents.send === 'function'; if (!hasWebContentsAPI) { return false; } // Check if isLoading is a function before calling it const isLoading = typeof webContents.isLoading === 'function' ? webContents.isLoading() : false; if (isLoading) { webContents.once('did-finish-load', () => { try { if (!webContents.isDestroyed()) { webContents.send(channel, data); } } catch (e) { } }); return true; } webContents.send(channel, data); return true; } catch (error) { return false; } }; /** * Set up cleanup when WebContents is destroyed */ const setupDestroyListener = (webContents, cleanup) => { try { if (typeof webContents.once === 'function') { webContents.once('destroyed', () => { cleanup(); }); } } catch (e) { } }; /** * Creates a WebContents tracker that uses WeakMap for automatic garbage collection * but maintains a set of active IDs for tracking purposes */ const createWebContentsTracker = () => { // WeakMap for the primary storage - won't prevent garbage collection const webContentsTracker = new WeakMap(); // Set to track active subscription IDs (not object references) const activeIds = new Set(); // Strong reference map of WebContents by ID - we need this to retrieve active WebContents // This will be maintained alongside the WeakMap const webContentsById = new Map(); return { track: (webContents) => { if (!webContents) { return false; } if (isDestroyed(webContents)) { return false; } const id = webContents.id; webContentsTracker.set(webContents, { id }); activeIds.add(id); webContentsById.set(id, webContents); // Set up the destroyed listener for cleanup setupDestroyListener(webContents, () => { activeIds.delete(id); webContentsById.delete(id); }); return true; }, untrack: (webContents) => { if (!webContents) { return; } const id = webContents.id; // Explicitly delete from all tracking structures webContentsTracker.delete(webContents); activeIds.delete(id); webContentsById.delete(id); }, untrackById: (id) => { activeIds.delete(id); const webContents = webContentsById.get(id); if (webContents) { webContentsTracker.delete(webContents); } webContentsById.delete(id); }, isTracked: (webContents) => { if (!webContents) { return false; } return webContents && webContentsTracker.has(webContents) && activeIds.has(webContents.id); }, hasId: (id) => { return activeIds.has(id); }, getActiveIds: () => { const ids = [...activeIds]; return ids; }, getActiveWebContents: () => { const result = []; // Filter out any destroyed WebContents that might still be in our map for (const [id, webContents] of webContentsById.entries()) { if (!isDestroyed(webContents)) { result.push(webContents); } else { activeIds.delete(id); webContentsById.delete(id); } } return result; }, cleanup: () => { activeIds.clear(); webContentsById.clear(); }, }; }; /** * Prepare WebContents objects from an array of wrappers or WebContents */ const prepareWebContents = (wrappers) => { if (!wrappers || !Array.isArray(wrappers)) { return []; } const result = []; for (const wrapper of wrappers) { const webContents = getWebContents(wrapper); if (webContents && !isDestroyed(webContents)) { result.push(webContents); } } return result; }; /** * Removes functions and non-serializable objects from a state object * to prevent IPC serialization errors when sending between processes * * @param state The state object to sanitize * @returns A new state object with functions and non-serializable parts removed */ const sanitizeState = (state) => { if (!state || typeof state !== 'object') return state; const safeState = {}; for (const key in state) { const value = state[key]; // Skip functions which cannot be cloned over IPC if (typeof value !== 'function') { if (value && typeof value === 'object' && !Array.isArray(value)) { // Recursively sanitize nested objects safeState[key] = sanitizeState(value); } else { safeState[key] = value; } } } return safeState; }; /** * Creates a core bridge between the main process and renderer processes * This implements the Zubridge Electron backend contract without requiring a specific state management library */ function createCoreBridge(stateManager, initialWrappers) { // Tracker for WebContents using WeakMap for automatic garbage collection const tracker = createWebContentsTracker(); // Initialize with initial wrappers if (initialWrappers) { const initialWebContents = prepareWebContents(initialWrappers); for (const webContents of initialWebContents) { tracker.track(webContents); } } // Handle dispatch events from renderers electron.ipcMain.on(IpcChannel.DISPATCH, (event, action) => { try { // Process the action through our state manager stateManager.processAction(action); // Send acknowledgment back to the sender if the action has an ID if (action.id) { event.sender.send(IpcChannel.DISPATCH_ACK, action.id); } } catch (error) { console.error('Error handling dispatch:', error); // Even on error, we should acknowledge the action was processed if (action.id) { event.sender.send(IpcChannel.DISPATCH_ACK, action.id); } } }); // Handle getState requests from renderers electron.ipcMain.handle(IpcChannel.GET_STATE, () => { try { return sanitizeState(stateManager.getState()); } catch (error) { console.error('Error handling getState:', error); return {}; } }); // Subscribe to state manager changes and broadcast to subscribed windows const stateManagerUnsubscribe = stateManager.subscribe((state) => { try { const activeIds = tracker.getActiveIds(); if (activeIds.length === 0) { return; } // Sanitize state before sending const safeState = sanitizeState(state); // Get active WebContents from our tracker const activeWebContents = tracker.getActiveWebContents(); // Send updates to all active WebContents that were explicitly subscribed for (const webContents of activeWebContents) { safelySendToWindow(webContents, IpcChannel.SUBSCRIBE, safeState); } } catch (error) { console.error('Error in state subscription handler:', error); } }); // Add new windows to tracking and subscriptions const subscribe = (newWrappers) => { const addedWebContents = []; // Handle invalid input cases if (!newWrappers || !Array.isArray(newWrappers)) { return { unsubscribe: () => { } }; } // Get WebContents from wrappers and track them for (const wrapper of newWrappers) { const webContents = getWebContents(wrapper); if (!webContents || isDestroyed(webContents)) { continue; } // Track the WebContents if (tracker.track(webContents)) { addedWebContents.push(webContents); // Send initial state const currentState = sanitizeState(stateManager.getState()); safelySendToWindow(webContents, IpcChannel.SUBSCRIBE, currentState); } } // Return an unsubscribe function return { unsubscribe: () => { for (const webContents of addedWebContents) { tracker.untrack(webContents); } }, }; }; // Remove windows from subscriptions const unsubscribe = (unwrappers) => { if (!unwrappers) { // If no wrappers are provided, unsubscribe all tracker.cleanup(); return; } for (const wrapper of unwrappers) { const webContents = getWebContents(wrapper); if (webContents) { tracker.untrack(webContents); } } }; // Get the list of currently subscribed window IDs const getSubscribedWindows = () => { return tracker.getActiveIds(); }; // Cleanup function to remove all listeners const destroy = () => { stateManagerUnsubscribe(); electron.ipcMain.removeHandler(IpcChannel.GET_STATE); // We can't remove the "on" listener cleanly in Electron, // but we can ensure we don't process any more dispatches tracker.cleanup(); }; return { subscribe, unsubscribe, getSubscribedWindows, destroy, }; } /** * Internal utility to create a bridge from a store * This is used by createZustandBridge and createReduxBridge * @internal */ function createBridgeFromStore(store, windows, options) { // Get or create a state manager for the store const stateManager = getStateManager(store, options); // Create the bridge using the state manager return createCoreBridge(stateManager, windows); } /** * Implementation that handles both overloads */ function createDispatch(storeOrManager, options) { // Get or create a state manager for the store or use the provided one const stateManager = 'processAction' in storeOrManager ? storeOrManager : getStateManager(storeOrManager, options); const dispatch = (actionOrThunk, payload) => { try { if (typeof actionOrThunk === 'function') { // Handle thunks return actionOrThunk(() => stateManager.getState(), dispatch); } else if (typeof actionOrThunk === 'string') { // Handle string action types with payload stateManager.processAction({ type: actionOrThunk, payload, }); } else if (actionOrThunk && typeof actionOrThunk === 'object') { // Handle action objects stateManager.processAction(actionOrThunk); } else { console.error('Invalid action or thunk:', actionOrThunk); } } catch (err) { console.error('Error in dispatch:', err); } }; return dispatch; } /** * Creates a bridge between a Zustand store and the renderer process */ function createZustandBridge(store, windows, options) { // Create the core bridge with the store const coreBridge = createBridgeFromStore(store, windows, options); // Create the dispatch function with the same store const dispatchFn = createDispatch(store, options); // Return bridge with all functionality return { subscribe: coreBridge.subscribe, unsubscribe: coreBridge.unsubscribe, getSubscribedWindows: coreBridge.getSubscribedWindows, destroy: () => { coreBridge.destroy(); // Clean up the state manager from the registry removeStateManager(store); }, dispatch: dispatchFn, }; } /** * Creates a bridge between a Redux store and the renderer process */ function createReduxBridge(store, windows, options) { // Create the core bridge with the store const coreBridge = createBridgeFromStore(store, windows, options); // Create the dispatch function with the same store const dispatchFn = createDispatch(store, options); // Return bridge with all functionality return { subscribe: coreBridge.subscribe, unsubscribe: coreBridge.unsubscribe, getSubscribedWindows: coreBridge.getSubscribedWindows, destroy: () => { coreBridge.destroy(); // Clean up the state manager from the registry removeStateManager(store); }, dispatch: dispatchFn, }; } /** * Legacy bridge alias for backward compatibility * @deprecated This is now an alias for createZustandBridge and uses the new IPC channels. * Please update your code to use createZustandBridge directly in the future. */ const mainZustandBridge = createZustandBridge; exports.createCoreBridge = createCoreBridge; exports.createDispatch = createDispatch; exports.createReduxBridge = createReduxBridge; exports.createZustandBridge = createZustandBridge; exports.mainZustandBridge = mainZustandBridge;