UNPKG

@zubridge/electron

Version:

A streamlined state management library for Electron applications using Zustand.

1,181 lines (1,169 loc) 114 kB
// Node.js build with bundled dependencies const require_mainThunkProcessor = require('./mainThunkProcessor-CxHMl3Vv.cjs'); let node_crypto = require("node:crypto"); let electron = require("electron"); let dequal = require("dequal"); let zod = require("zod"); //#region src/utils/errorHandling.ts /** * Enhanced error logging that preserves Error instances */ function logZubridgeError(error, additionalInfo) { require_mainThunkProcessor.debugLog(`${error.constructor.name.replace("Error", "").toLowerCase()}:error`, `${error.name}:`, { ...error.getDetails(), ...additionalInfo }); } /** * Convert error to serializable format for IPC without losing type info */ function serializeError(error) { const zubridgeError = require_mainThunkProcessor.ensureZubridgeError(error); return { name: zubridgeError.name, message: zubridgeError.message, stack: zubridgeError.stack, timestamp: zubridgeError.timestamp, context: zubridgeError.context }; } //#endregion //#region src/utils/handlers.ts const handlerResolutionCache = /* @__PURE__ */ new WeakMap(); const CACHE_TTL_MS = 300 * 1e3; const MAX_CACHE_SIZE = 1e3; /** * Gets or creates a cache Map for a specific handlers object */ function getCacheForHandlers(handlers) { let cache = handlerResolutionCache.get(handlers); if (!cache) { cache = /* @__PURE__ */ new Map(); handlerResolutionCache.set(handlers, cache); } return cache; } /** * Cleans up expired cache entries for a specific handlers object */ function cleanupExpiredCacheEntries(handlersCache) { const now = Date.now(); let cleanedCount = 0; for (const [key, entry] of handlersCache.entries()) if (now - entry.timestamp > CACHE_TTL_MS) { handlersCache.delete(key); cleanedCount++; } if (cleanedCount > 0) require_mainThunkProcessor.debugLog("handlers:cache", `Cleaned up ${cleanedCount} expired handler cache entries`); } /** * Manages cache size to prevent memory leaks for a specific handlers object */ function manageCacheSize(handlersCache) { if (handlersCache.size >= MAX_CACHE_SIZE) { const entriesToRemove = handlersCache.size - Math.floor(MAX_CACHE_SIZE * .8); let removedCount = 0; for (const key of handlersCache.keys()) { if (removedCount >= entriesToRemove) break; handlersCache.delete(key); removedCount++; } require_mainThunkProcessor.debugLog("handlers:cache", `Removed ${removedCount} handler cache entries to manage size`); } } /** * Helper function to find a case-insensitive match in an object */ function findCaseInsensitiveMatch(obj, key) { if (key in obj) { require_mainThunkProcessor.debugLog("store", `Found exact match for handler key: ${key}`); return [key, obj[key]]; } const keyLower = key.toLowerCase(); const matchingKey = Object.keys(obj).find((k) => k.toLowerCase() === keyLower); if (matchingKey) { require_mainThunkProcessor.debugLog("store", `Found case-insensitive match for handler key '${key}': ${matchingKey}`); return [matchingKey, obj[matchingKey]]; } require_mainThunkProcessor.debugLog("store", `No match found for handler key: ${key}`); } /** * Helper function to find a handler by nested path * Example: "counter.increment" -> obj.counter.increment */ function findNestedHandler(obj, path) { try { require_mainThunkProcessor.debugLog("store", `Resolving nested handler for path: ${path}`); const parts = path.split("."); let current = obj; for (let i = 0; i < parts.length; i++) { const part = parts[i]; const matchingKey = Object.keys(current).find((k) => k.toLowerCase() === part.toLowerCase()); if (matchingKey === void 0) { require_mainThunkProcessor.debugLog("store", `Could not find part '${part}' in path '${path}'`); return; } current = current[matchingKey]; require_mainThunkProcessor.debugLog("store", `Resolved part '${part}' to '${matchingKey}', continuing resolution`); } if (typeof current === "function") { require_mainThunkProcessor.debugLog("store", `Successfully resolved handler for path: ${path}`); return current; } require_mainThunkProcessor.debugLog("store", `Found value for path ${path}, but it's not a function`); return; } catch (error) { require_mainThunkProcessor.debugLog("store", "Error resolving nested handler:", error); return; } } /** * Resolves a handler function from provided handlers using action type * This handles both direct matches and nested path resolution * Uses WeakMap-based caching to improve performance while automatically invalidating when handlers change */ function resolveHandler(handlers, actionType) { require_mainThunkProcessor.debugLog("store", `Resolving handler for action type: ${actionType}`); const handlersCache = getCacheForHandlers(handlers); const now = Date.now(); const cachedEntry = handlersCache.get(actionType); if (cachedEntry && now - cachedEntry.timestamp < CACHE_TTL_MS) { require_mainThunkProcessor.debugLog("store", `Found cached handler resolution for action type: ${actionType}`); return cachedEntry.handler; } require_mainThunkProcessor.debugLog("store", `Cache miss, performing handler resolution for action type: ${actionType}`); let resolvedHandler; const handlerMatch = findCaseInsensitiveMatch(handlers, actionType); if (handlerMatch && typeof handlerMatch[1] === "function") { require_mainThunkProcessor.debugLog("store", `Found direct handler match for action type: ${actionType}`); resolvedHandler = handlerMatch[1]; } else { require_mainThunkProcessor.debugLog("store", `No direct handler match, trying nested path resolution for: ${actionType}`); resolvedHandler = findNestedHandler(handlers, actionType); } manageCacheSize(handlersCache); handlersCache.set(actionType, { handler: resolvedHandler, timestamp: now }); if (Math.random() < .01) cleanupExpiredCacheEntries(handlersCache); return resolvedHandler; } //#endregion //#region src/utils/serialization.ts /** * Type guard to check if a value is a Promise */ const isPromise = (value) => value !== null && typeof value === "object" && typeof value.then === "function"; /** * Removes functions and non-serializable objects from a state object * to prevent IPC serialization errors when sending between processes * * This implementation prevents memory leaks and stack overflow by: * - Using WeakSet to detect circular references * - Limiting recursion depth * - Handling special object types (Date, RegExp, etc.) * - Providing clear error boundaries * * @param state The state object to sanitize * @param options Serialization options * @returns A new state object with functions and non-serializable parts removed */ const sanitizeState = (state, options) => { const { maxDepth = 10, replacer, filterKeys, includeNonEnumerable = false } = options || {}; const seen = /* @__PURE__ */ new WeakSet(); function serialize(value, currentDepth = 0, currentKey = "") { if (currentDepth > maxDepth) return `[Max Depth Exceeded: ${currentKey}]`; if (value === null || value === void 0) return value; if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") return value; if (typeof value === "bigint") return `${value.toString()}n`; if (typeof value === "symbol") return `[Symbol: ${value.toString()}]`; if (typeof value === "function") return; if (typeof value !== "object") return `[Unknown Type: ${typeof value}]`; if (seen.has(value)) return "[Circular Reference]"; seen.add(value); try { if (value instanceof Date) return value.toISOString(); if (value instanceof RegExp) return `[RegExp: ${value.toString()}]`; if (value instanceof Error) return { name: value.name, message: value.message, stack: value.stack }; if (value instanceof Map) { const entries = []; for (const [k, v] of value.entries()) entries.push([serialize(k, currentDepth + 1, `${currentKey}[Map Key]`), serialize(v, currentDepth + 1, `${currentKey}[Map Value]`)]); return { __type: "Map", entries }; } if (value instanceof Set) { const values = []; for (const v of value.values()) values.push(serialize(v, currentDepth + 1, `${currentKey}[Set Value]`)); return { __type: "Set", values }; } if (ArrayBuffer.isView(value)) return `[${value.constructor.name}: ${value.byteLength} bytes]`; if (value instanceof ArrayBuffer) return `[ArrayBuffer: ${value.byteLength} bytes]`; if (Array.isArray(value)) return value.map((item, index) => serialize(item, currentDepth + 1, `${currentKey}[${index}]`)); const result = {}; const obj = value; let keysToProcess; if (filterKeys) keysToProcess = filterKeys.filter((key) => key in obj); else keysToProcess = includeNonEnumerable ? Object.getOwnPropertyNames(obj) : Object.keys(obj); for (const key of keysToProcess) try { const propValue = obj[key]; const keyPath = currentKey ? `${currentKey}.${key}` : key; const valueToSerialize = replacer ? replacer(key, propValue) : propValue; if (valueToSerialize !== void 0) { const serializedValue = serialize(valueToSerialize, currentDepth + 1, keyPath); if (serializedValue !== void 0) result[key] = serializedValue; } } catch (error) { result[key] = `[Error accessing property: ${error instanceof Error ? error.message : String(error)}]`; } return result; } catch (error) { return `[Serialization Error: ${error instanceof Error ? error.message : String(error)}]`; } finally { seen.delete(value); } } try { if (!state || typeof state !== "object") return state; return serialize(state); } catch (error) { require_mainThunkProcessor.debugLog("serialization:error", "Critical serialization error:", error); return { __serializationError: true, message: error instanceof Error ? error.message : String(error), originalType: typeof state }; } }; //#endregion //#region src/adapters/redux.ts /** * 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) { require_mainThunkProcessor.debugLog("adapters", "Creating Redux adapter", options); return { getState: () => store.getState(), subscribe: (listener) => store.subscribe(() => listener(store.getState())), processAction: (action) => { try { require_mainThunkProcessor.debugLog("adapters", "Redux adapter processing action:", action); if (options?.handlers) { require_mainThunkProcessor.debugLog("adapters", "Checking for handler in custom handlers"); const handler = resolveHandler(options.handlers, action.type); if (handler) { require_mainThunkProcessor.debugLog("adapters", `Found custom handler for action type: ${action.type}`); require_mainThunkProcessor.debugLog("adapters", `Executing handler for ${action.type}, time: ${(/* @__PURE__ */ new Date()).toISOString()}`); const startTime = Date.now(); const result = handler(action.payload); if (isPromise(result)) { require_mainThunkProcessor.debugLog("adapters", `Handler for ${action.type} returned a Promise, it will complete asynchronously`); return { isSync: false, completion: result.then(() => { const endTime$1 = Date.now(); require_mainThunkProcessor.debugLog("adapters", `Async handler for ${action.type} completed in ${endTime$1 - startTime}ms, time: ${(/* @__PURE__ */ new Date()).toISOString()}`); }).catch((error) => { const endTime$1 = Date.now(); const actionError = new require_mainThunkProcessor.ActionProcessingError(`Async handler execution failed for action ${action.type}`, action.type, "redux", { duration: endTime$1 - startTime, handlerName: "async_handler", originalError: error }); logZubridgeError(actionError); return { error: actionError.message }; }) }; } const endTime = Date.now(); require_mainThunkProcessor.debugLog("adapters", `Sync handler for ${action.type} completed in ${endTime - startTime}ms`); return { isSync: true }; } } require_mainThunkProcessor.debugLog("adapters", `Dispatching action to Redux store: ${action.type}`); store.dispatch(action); return { isSync: true }; } catch (error) { const actionError = new require_mainThunkProcessor.ActionProcessingError(`Synchronous action processing failed for action ${action.type}`, action.type, "redux", { context: "sync_process_action", originalError: error }); logZubridgeError(actionError); return { isSync: true, error: actionError.message }; } } }; } //#endregion //#region src/adapters/zustand.ts /** * Creates a state manager adapter for Zustand stores */ function createZustandAdapter(store, options) { require_mainThunkProcessor.debugLog("adapters", "Creating Zustand adapter", options); return { getState: () => store.getState(), subscribe: (listener) => store.subscribe(listener), processAction: (action) => { try { require_mainThunkProcessor.debugLog("adapters", "Zustand adapter processing action:", action); if (options?.handlers) { require_mainThunkProcessor.debugLog("adapters", "Checking for handler in custom handlers"); const handler = resolveHandler(options.handlers, action.type); if (handler) { require_mainThunkProcessor.debugLog("adapters", `Found custom handler for action type: ${action.type}`); require_mainThunkProcessor.debugLog("adapters", `Executing handler for ${action.type}, time: ${(/* @__PURE__ */ new Date()).toISOString()}`); const startTime = Date.now(); const result = handler(action.payload); if (isPromise(result)) { require_mainThunkProcessor.debugLog("adapters", `Handler for ${action.type} returned a Promise, it will complete asynchronously`); const promiseId = Math.random().toString(36).substring(2, 10); require_mainThunkProcessor.debugLog("adapters", `[ADAPTER_DEBUG] [${promiseId}] Creating async handler promise wrapper for action: ${action.type}`); return { isSync: false, completion: result.then(() => { const endTime$1 = Date.now(); require_mainThunkProcessor.debugLog("adapters", `[ADAPTER_DEBUG] [${promiseId}] Async handler promise for ${action.type} RESOLVED after ${endTime$1 - startTime}ms`); require_mainThunkProcessor.debugLog("adapters", `Async handler for ${action.type} completed in ${endTime$1 - startTime}ms, time: ${(/* @__PURE__ */ new Date()).toISOString()}`); require_mainThunkProcessor.debugLog("adapters", `[ADAPTER_DEBUG] [${promiseId}] STATE IS NOW UPDATED for ${action.type}`); }).catch((error) => { const endTime$1 = Date.now(); const actionError = new require_mainThunkProcessor.ActionProcessingError(`Async handler execution failed for action ${action.type}`, action.type, "zustand", { promiseId, duration: endTime$1 - startTime, handlerName: "async_handler", originalError: error }); logZubridgeError(actionError); return { error: actionError.message }; }) }; } const endTime = Date.now(); require_mainThunkProcessor.debugLog("adapters", `Sync handler for ${action.type} completed in ${endTime - startTime}ms`); return { isSync: true }; } } if (options?.reducer) { require_mainThunkProcessor.debugLog("adapters", "Using reducer to handle action"); store.setState(options.reducer(store.getState(), action)); return { isSync: true }; } if (action.type === "setState") { require_mainThunkProcessor.debugLog("adapters", "Processing setState action"); store.setState(action.payload); return { isSync: true }; } require_mainThunkProcessor.debugLog("adapters", "Looking for action handler in store state"); const state = store.getState(); const methodMatch = findCaseInsensitiveMatch(Object.fromEntries(Object.entries(state).filter(([_, value]) => typeof value === "function")), action.type); if (methodMatch && typeof methodMatch[1] === "function") { require_mainThunkProcessor.debugLog("adapters", `Found direct method match in store state: ${methodMatch[0]}`); const result = methodMatch[1](action.payload); if (isPromise(result)) { require_mainThunkProcessor.debugLog("adapters", `Method ${methodMatch[0]} returned a Promise, it will complete asynchronously`); return { isSync: false, completion: result.then(() => { require_mainThunkProcessor.debugLog("adapters", `Async method ${methodMatch[0]} completed`); }).catch((error) => { const actionError = new require_mainThunkProcessor.ActionProcessingError(`Async method execution failed for ${methodMatch[0]}`, action.type, "zustand", { methodName: methodMatch[0], originalError: error }); logZubridgeError(actionError); return { error: actionError.message }; }) }; } return { isSync: true }; } require_mainThunkProcessor.debugLog("adapters", "Trying nested path resolution for handler in store state"); const nestedStateHandler = findNestedHandler(state, action.type); if (nestedStateHandler) { require_mainThunkProcessor.debugLog("adapters", `Found nested handler in store state for: ${action.type}`); const result = nestedStateHandler(action.payload); if (isPromise(result)) { require_mainThunkProcessor.debugLog("adapters", `Nested handler for ${action.type} returned a Promise, it will complete asynchronously`); return { isSync: false, completion: result.then(() => { require_mainThunkProcessor.debugLog("adapters", `Async nested handler for ${action.type} completed`); }).catch((error) => { const actionError = new require_mainThunkProcessor.ActionProcessingError(`Async nested handler execution failed for action ${action.type}`, action.type, "zustand", { handlerName: "nested_handler", originalError: error }); logZubridgeError(actionError); return { error: actionError.message }; }) }; } return { isSync: true }; } require_mainThunkProcessor.debugLog("adapters", `No handler found for action type: ${action.type}`); return { isSync: true }; } catch (error) { const actionError = new require_mainThunkProcessor.ActionProcessingError(`Synchronous action processing failed for action ${action.type}`, action.type, "zustand", { context: "sync_process_action", originalError: error }); logZubridgeError(actionError); return { isSync: true, error: actionError.message }; } } }; } //#endregion //#region src/registry/stateManagerRegistry.ts let stateManagerRegistry = /* @__PURE__ */ 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) { require_mainThunkProcessor.debugLog("state-manager", "[DEBUG] getStateManager called"); if (stateManagerRegistry.has(store)) { require_mainThunkProcessor.debugLog("state-manager", "[DEBUG] Returning cached state manager"); return stateManagerRegistry.get(store); } let stateManager; if ("setState" in store) { require_mainThunkProcessor.debugLog("state-manager", "[DEBUG] Creating Zustand adapter"); stateManager = createZustandAdapter(store, options); } else if ("dispatch" in store) { require_mainThunkProcessor.debugLog("state-manager", "[DEBUG] Creating Redux adapter"); stateManager = createReduxAdapter(store, options); } else throw new Error("Unrecognized store type. Must be a Zustand StoreApi or Redux Store."); 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); } //#endregion //#region src/middleware.ts /** * Helper to create bridge middleware options for Electron bridges * * This function integrates Rust-based middleware (@zubridge/middleware) with the Electron bridge. * * Note: You typically don't need to use this function directly. You can just pass the middleware * instance directly to the bridge creation functions. * * @example * ```typescript * import { createZustandBridge } from '@zubridge/electron'; * import { initZubridgeMiddleware } from '@zubridge/middleware'; * * // Initialize middleware from @zubridge/middleware (Rust implementation) * const middleware = initZubridgeMiddleware({ * logging: { enabled: true } * }); * * // Create bridge with Rust middleware - just pass middleware directly * const bridge = createZustandBridge(store, windows, { * // Your other options * middleware: middleware * }); * ``` * * @internal This function is used internally by bridge creation functions */ function createMiddlewareOptions(middleware) { return { beforeProcessAction: async (action) => { try { require_mainThunkProcessor.debugLog("core", "Applying middleware.processAction to action:", action); let payloadForNapi; if (action.payload !== void 0 && action.payload !== null) if (typeof action.payload === "string") payloadForNapi = action.payload; else try { payloadForNapi = JSON.stringify(action.payload); } catch (stringifyError) { require_mainThunkProcessor.debugLog("core", "[zubridge-electron] Error stringifying action payload for NAPI middleware:", stringifyError); payloadForNapi = JSON.stringify({ error: "Payload stringification failed" }); } const napiCompliantAction = { type: action.type, payload: payloadForNapi }; await middleware.processAction(napiCompliantAction); } catch (error) { require_mainThunkProcessor.debugLog("core:error", "Error in zubridge middleware processAction:", error); } return action; }, afterStateChange: async (state) => { try { require_mainThunkProcessor.debugLog("core", "Applying middleware.setState with updated state"); let stateJson; try { stateJson = JSON.stringify(state); } catch (stringifyError) { require_mainThunkProcessor.debugLog("core:error", "[zubridge-electron] Error stringifying state for NAPI middleware:", stringifyError); stateJson = JSON.stringify({ error: "State stringification failed" }); } await middleware.setState(stateJson); } catch (error) { require_mainThunkProcessor.debugLog("core:error", "Error in zubridge middleware setState:", error); } }, onBridgeDestroy: async () => { if (middleware.destroy) try { require_mainThunkProcessor.debugLog("core", "Destroying middleware instance"); await middleware.destroy(); } catch (error) { require_mainThunkProcessor.debugLog("core:error", "Error destroying zubridge middleware:", error); } } }; } //#endregion //#region src/utils/globalErrorHandlers.ts /** * Sets up global error handlers for the main process to catch unhandled errors * and promise rejections that could otherwise crash the application */ function setupMainProcessErrorHandlers() { process.on("unhandledRejection", (reason, promise) => { logZubridgeError(new require_mainThunkProcessor.ResourceManagementError("Unhandled promise rejection detected", "promise", "cleanup", { reason: reason instanceof Error ? reason.message : String(reason), originalReason: reason, promiseString: promise.toString() })); require_mainThunkProcessor.debugLog("process:error", "Unhandled promise rejection detected and logged"); }); process.on("uncaughtException", (error) => { logZubridgeError(new require_mainThunkProcessor.ResourceManagementError("Uncaught exception detected", "process", "cleanup", { originalError: error, stack: error.stack })); require_mainThunkProcessor.debugLog("process:error", "Uncaught exception detected and logged"); setTimeout(() => { process.exit(1); }, 100); }); require_mainThunkProcessor.debugLog("process", "Global error handlers setup for main process"); } //#endregion //#region src/utils/windows.ts /** * Type guard to check if an object is an Electron WebContents */ const isWebContents = (wrapperOrWebContents) => { const result = wrapperOrWebContents && typeof wrapperOrWebContents === "object" && "id" in wrapperOrWebContents; if (result) require_mainThunkProcessor.debugLog("windows", `isWebContents: TRUE for id ${wrapperOrWebContents.id}`); else require_mainThunkProcessor.debugLog("windows", "isWebContents: FALSE", wrapperOrWebContents); return result; }; /** * Type guard to check if an object is a WebContentsWrapper */ const isWrapper = (wrapperOrWebContents) => { const result = wrapperOrWebContents && typeof wrapperOrWebContents === "object" && "webContents" in wrapperOrWebContents; if (result) require_mainThunkProcessor.debugLog("windows", `isWrapper: TRUE for id ${wrapperOrWebContents.webContents?.id}`); else require_mainThunkProcessor.debugLog("windows", "isWrapper: FALSE", wrapperOrWebContents); return result; }; /** * Get the WebContents object from either a WebContentsWrapper or WebContents */ const getWebContents = (wrapperOrWebContents) => { let description = "Invalid input"; if (wrapperOrWebContents && typeof wrapperOrWebContents === "object") if ("id" in wrapperOrWebContents) description = `WebContents ID: ${wrapperOrWebContents.id}`; else if ("webContents" in wrapperOrWebContents) description = `Wrapper with WebContents ID: ${wrapperOrWebContents.webContents?.id}`; else description = "Unknown object type"; require_mainThunkProcessor.debugLog("windows", `getWebContents called with: ${description}`); if (isWebContents(wrapperOrWebContents)) { require_mainThunkProcessor.debugLog("windows", `getWebContents: Returning direct WebContents with ID: ${wrapperOrWebContents.id}`); return wrapperOrWebContents; } if (isWrapper(wrapperOrWebContents)) { const webContents$1 = wrapperOrWebContents.webContents; require_mainThunkProcessor.debugLog("windows", `getWebContents: Extracting from wrapper, ID: ${webContents$1?.id || "undefined"}`); return webContents$1; } require_mainThunkProcessor.debugLog("windows", "getWebContents: Could not extract WebContents, returning undefined"); }; /** * Check if a WebContents is destroyed */ const isDestroyed = (webContents$1) => { try { if (typeof webContents$1.isDestroyed === "function") { const destroyed = webContents$1.isDestroyed(); require_mainThunkProcessor.debugLog("windows", `isDestroyed check for WebContents ID ${webContents$1.id}: ${destroyed}`); return destroyed; } require_mainThunkProcessor.debugLog("windows", `isDestroyed: WebContents ID ${webContents$1?.id} has no isDestroyed function`); return false; } catch (error) { require_mainThunkProcessor.debugLog("windows", `isDestroyed: Exception while checking ID ${webContents$1?.id}`, error); return true; } }; const pendingMessages = /* @__PURE__ */ new Map(); /** * Safely send a message to a WebContents */ const safelySendToWindow = (webContents$1, channel, data) => { try { require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: Attempting to send to WebContents ID ${webContents$1?.id}, channel: ${channel}`); if (!webContents$1 || isDestroyed(webContents$1)) { require_mainThunkProcessor.debugLog("windows", "safelySendToWindow: WebContents is undefined or destroyed, aborting send"); return false; } if (!(typeof webContents$1.send === "function")) { require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: WebContents ID ${webContents$1.id} missing 'send' function`); return false; } const isLoading = typeof webContents$1.isLoading === "function" ? webContents$1.isLoading() : false; require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: WebContents ID ${webContents$1.id} isLoading: ${isLoading}`); if (isLoading) { require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: WebContents ID ${webContents$1.id} is loading, queueing message for later`); const wcId = webContents$1.id; const queue = pendingMessages.get(wcId) ?? []; if (!pendingMessages.has(wcId)) { pendingMessages.set(wcId, queue); webContents$1.once("destroyed", () => pendingMessages.delete(wcId)); webContents$1.once("did-finish-load", () => { const messages = pendingMessages.get(wcId); pendingMessages.delete(wcId); if (!messages) return; try { if (!webContents$1.isDestroyed()) { require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: Flushing ${messages.length} queued message(s) to WebContents ID ${wcId}`); for (const msg of messages) webContents$1.send(msg.channel, msg.data); } else require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: WebContents ID ${wcId} was destroyed before load finished`); } catch (e) { require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: Error sending delayed messages to WebContents ID ${wcId}`, e); } }); } if (queue.length >= 1e3) { queue.shift(); require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: queue limit reached for WebContents ID ${wcId}, dropping oldest message`); } queue.push({ channel, data }); return true; } require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: Sending message immediately to WebContents ID ${webContents$1.id}`); webContents$1.send(channel, data); return true; } catch (error) { require_mainThunkProcessor.debugLog("windows", `safelySendToWindow: Exception while sending to WebContents ID ${webContents$1?.id}`, error); return false; } }; /** * Set up cleanup when WebContents is destroyed */ const setupDestroyListener = (webContents$1, cleanup) => { try { require_mainThunkProcessor.debugLog("windows", `setupDestroyListener: Setting up cleanup for WebContents ID ${webContents$1?.id}`); if (typeof webContents$1.once === "function") webContents$1.once("destroyed", () => { require_mainThunkProcessor.debugLog("windows", `WebContents ID ${webContents$1.id} destroyed, running cleanup`); cleanup(); }); else require_mainThunkProcessor.debugLog("windows", `setupDestroyListener: WebContents ID ${webContents$1.id} missing 'once' function`); } catch (e) { require_mainThunkProcessor.debugLog("windows", `setupDestroyListener: Exception for WebContents ID ${webContents$1?.id}`, e); } }; /** * Creates a WebContents tracker that uses WeakMap for automatic garbage collection * but maintains a set of active IDs for tracking purposes */ const createWebContentsTracker = () => { require_mainThunkProcessor.debugLog("windows", "Creating new WebContentsTracker"); const webContentsTracker = /* @__PURE__ */ new WeakMap(); const activeIds = /* @__PURE__ */ new Set(); const webContentsById = /* @__PURE__ */ new Map(); return { track: (webContents$1) => { if (!webContents$1) { require_mainThunkProcessor.debugLog("windows", "track: Called with undefined WebContents"); return false; } if (isDestroyed(webContents$1)) { require_mainThunkProcessor.debugLog("windows", `track: WebContents ID ${webContents$1.id} is already destroyed`); return false; } const id = webContents$1.id; const alreadyTracked = activeIds.has(id); require_mainThunkProcessor.debugLog("windows", `track: ${alreadyTracked ? "Re-tracking" : "Adding"} WebContents ID ${id} to tracker`); webContentsTracker.set(webContents$1, { id }); activeIds.add(id); webContentsById.set(id, webContents$1); if (!alreadyTracked) setupDestroyListener(webContents$1, () => { require_mainThunkProcessor.debugLog("windows", `track: Cleanup handler for WebContents ID ${id} triggered`); activeIds.delete(id); webContentsById.delete(id); }); return true; }, untrack: (webContents$1) => { if (!webContents$1) { require_mainThunkProcessor.debugLog("windows", "untrack: Called with undefined WebContents"); return; } const id = webContents$1.id; require_mainThunkProcessor.debugLog("windows", `untrack: Removing WebContents ID ${id} from tracker`); webContentsTracker.delete(webContents$1); activeIds.delete(id); webContentsById.delete(id); }, untrackById: (id) => { require_mainThunkProcessor.debugLog("windows", `untrackById: Removing ID ${id} from tracker`); activeIds.delete(id); const webContents$1 = webContentsById.get(id); if (webContents$1) { require_mainThunkProcessor.debugLog("windows", `untrackById: Found and removing WebContents for ID ${id}`); webContentsTracker.delete(webContents$1); } webContentsById.delete(id); }, isTracked: (webContents$1) => { if (!webContents$1) { require_mainThunkProcessor.debugLog("windows", "isTracked: Called with undefined WebContents"); return false; } const tracked = webContents$1 && webContentsTracker.has(webContents$1) && activeIds.has(webContents$1.id); require_mainThunkProcessor.debugLog("windows", `isTracked: WebContents ID ${webContents$1.id} tracked: ${tracked}`); return tracked; }, hasId: (id) => { const has = activeIds.has(id); require_mainThunkProcessor.debugLog("windows", `hasId: ID ${id} in tracker: ${has}`); return has; }, getActiveIds: () => { const ids = [...activeIds]; require_mainThunkProcessor.debugLog("windows", `getActiveIds: Returning ${ids.length} active IDs: ${ids.join(", ")}`); return ids; }, getActiveWebContents: () => { require_mainThunkProcessor.debugLog("windows", "getActiveWebContents: Collecting active WebContents"); const result = []; for (const [id, webContents$1] of webContentsById.entries()) if (!isDestroyed(webContents$1)) { require_mainThunkProcessor.debugLog("windows", `getActiveWebContents: Adding active WebContents ID ${id}`); result.push(webContents$1); } else { require_mainThunkProcessor.debugLog("windows", `getActiveWebContents: Found destroyed WebContents ID ${id}, cleaning up`); activeIds.delete(id); webContentsById.delete(id); } require_mainThunkProcessor.debugLog("windows", `getActiveWebContents: Returning ${result.length} active WebContents`); return result; }, cleanup: () => { require_mainThunkProcessor.debugLog("windows", `cleanup: Clearing all tracked WebContents (${activeIds.size} IDs)`); activeIds.clear(); webContentsById.clear(); } }; }; //#endregion //#region src/utils/deepGet.ts function deepGet(obj, key, def) { let p; let undef; const path = typeof key === "string" ? key.split(".") : key; let currentObj = obj; for (p = 0; p < path.length; p++) currentObj = currentObj && typeof currentObj === "object" ? currentObj[path[p]] : undef; return currentObj === undef ? def : currentObj; } //#endregion //#region src/subscription/SubscriptionManager.ts /** * Normalizes keys for deduplication: sorts and joins with ',', or '*' for full-state. * Returns: * - '*' for full state subscription (when keys is undefined or contains '*') * - [] for empty array (explicitly subscribing to nothing) * - Array of specific keys otherwise */ function normalizeKeys(keys) { require_mainThunkProcessor.debugLog("subscription", `[normalizeKeys] Input keys: ${keys ? keys.join(", ") : "undefined"}`); if (!keys) { require_mainThunkProcessor.debugLog("subscription", "[normalizeKeys] No keys provided, returning \"*\" (all state)"); return "*"; } if (keys.length === 0) { require_mainThunkProcessor.debugLog("subscription", "[normalizeKeys] Empty keys array, returning [] (no state)"); return []; } if (keys.includes("*")) { require_mainThunkProcessor.debugLog("subscription", "[normalizeKeys] \"*\" found in keys, returning \"*\" (all state)"); return "*"; } const normalized = [...new Set(keys.map((k) => k.trim()).filter((k) => k.length > 0))].sort(); require_mainThunkProcessor.debugLog("subscription", `[normalizeKeys] Normalized keys: ${normalized.join(", ")}`); return normalized; } /** * Extracts a partial state object for the given keys using deepGet. */ function getPartialState(state, keys) { const normalized = normalizeKeys(keys); if (normalized === "*") return { ...state }; if (normalized.length === 0) return {}; const result = {}; for (const key of normalized) { const value = deepGet(state, key); if (value !== void 0) setDeep(result, key, value); } return result; } /** * Sets a deep value in an object at the given path (dot notation). */ function setDeep(obj, path, value) { const keys = path.split("."); let curr = obj; for (let i = 0; i < keys.length - 1; i++) { if (curr[keys[i]] == null || typeof curr[keys[i]] !== "object") curr[keys[i]] = {}; else curr[keys[i]] = { ...curr[keys[i]] }; curr = curr[keys[i]]; } curr[keys[keys.length - 1]] = value; } /** * Checks if any of the subscribed keys have changed between prev and next state. */ function hasRelevantChange(prev, next, keys) { require_mainThunkProcessor.debugLog("subscription", "[hasRelevantChange] Comparing states:", { prev: prev === void 0 ? "undefined" : JSON.stringify(prev), next: JSON.stringify(next), keys }); if (prev === void 0) return true; const normalized = normalizeKeys(keys); if (normalized === "*") { if (prev === next) { require_mainThunkProcessor.debugLog("subscription", "[hasRelevantChange] Full state subscription - same reference, skipping"); return false; } require_mainThunkProcessor.debugLog("subscription", "[hasRelevantChange] Full state subscription - notifying"); return true; } if (normalized.length === 0) return false; return normalized.some((key) => { const prevValue = deepGet(prev, key); const nextValue = deepGet(next, key); const changed = !(0, dequal.dequal)(prevValue, nextValue); require_mainThunkProcessor.debugLog("subscription", `[hasRelevantChange] Comparing key ${key}:`, { prevValue, nextValue, changed }); return changed; }); } var SubscriptionManager = class { subscriptions = /* @__PURE__ */ new Map(); nextSubId = 0; generateSubId(windowId) { return `window-${windowId}-sub-${this.nextSubId++}`; } /** * Subscribe to state changes for specific keys (deep keys supported). * Each call creates an independent subscription entry so that multiple * subscriptions on the same window do not overwrite each other's callbacks. * * Returns `{ status: 'registered', unsubscribe }` when the callback was * registered, or `{ status: 'superseded' }` when an existing '*' (all-state) * subscription already covers this window and the requested keys are * specific. Superseded callers should skip sending an initial-state delta — * the existing '*' subscription already delivers state to the window. * * A '*' subscription replaces all prior entries for the window (both * specific-key and prior '*' entries). Previously returned unsubscribe * handles become no-ops — this is intentional: '*' supersedes everything. * Callers must use the '*' subscription's unsubscribe handle to fully * clean up; prior handles will not remove the '*' entry. */ subscribe(keys, callback, windowId) { require_mainThunkProcessor.debugLog("subscription", `[subscribe] Called with keys: ${keys ? JSON.stringify(keys) : "undefined"} for window ${windowId}`); require_mainThunkProcessor.debugLog("subscription", `[subscribe] Current subscriptions size: ${this.subscriptions.size}`); const existingKeys = this.getCurrentSubscriptionKeys(windowId); require_mainThunkProcessor.debugLog("subscription", `[subscribe] Existing subscriptions for window ${windowId}:`, existingKeys); const normalized = normalizeKeys(keys); require_mainThunkProcessor.debugLog("subscription", "[subscribe] Normalized keys:", normalized); if (existingKeys.includes("*") && normalized !== "*") { require_mainThunkProcessor.debugLog("subscription", `[subscribe] Window ${windowId} already has '*' subscription, keeping it`); return { status: "superseded" }; } const subId = this.generateSubId(windowId); require_mainThunkProcessor.debugLog("subscription", `[subscribe] Using subscription id: ${subId}`); if (normalized === "*") { require_mainThunkProcessor.debugLog("subscription", `[subscribe] Setting full '*' subscription for window ${windowId}`); for (const [id, sub] of this.subscriptions) if (sub.windowId === windowId) this.subscriptions.delete(id); this.subscriptions.set(subId, { keys: void 0, callback, windowId }); } else { require_mainThunkProcessor.debugLog("subscription", `[subscribe] Adding subscription for window ${windowId}:`, normalized); this.subscriptions.set(subId, { keys: normalized, callback, windowId }); } require_mainThunkProcessor.debugLog("subscription", `[subscribe] New subscriptions size: ${this.subscriptions.size}`); const currentSubscriptions = this.getCurrentSubscriptionKeys(windowId); require_mainThunkProcessor.debugLog("subscription", `[subscribe] Current subscriptions for window ${windowId}:`, currentSubscriptions); return { status: "registered", unsubscribe: () => { this.subscriptions.delete(subId); } }; } /** * Unsubscribe a window from specific keys, or all if no keys provided. * This is a window-wide operation — it removes matching keys from all * subscriptions for the window, regardless of which callback registered them. * For per-subscription cleanup, use the `unsubscribe` handle returned by * `subscribe()` instead. */ unsubscribe(keys, windowId) { require_mainThunkProcessor.debugLog("subscription", `[unsubscribe] Called with keys: ${keys ? JSON.stringify(keys) : "undefined"} for window ${windowId}`); if (keys && keys.length === 0) { require_mainThunkProcessor.debugLog("subscription", `[unsubscribe] Empty keys array, no-op for window ${windowId}`); return; } if (!keys || keys.includes("*")) { require_mainThunkProcessor.debugLog("subscription", `[unsubscribe] Removing all subscriptions for window ${windowId}`); for (const [id, sub] of this.subscriptions) if (sub.windowId === windowId) this.subscriptions.delete(id); return; } for (const [id, sub] of this.subscriptions) { if (sub.windowId !== windowId) continue; if (sub.keys === void 0) { require_mainThunkProcessor.debugLog("subscription", `[unsubscribe] Keeping '*' subscription for window ${windowId}`); continue; } const remainingKeys = sub.keys.filter((key) => !keys.includes(key)); require_mainThunkProcessor.debugLog("subscription", `[unsubscribe] Remaining keys for subscription ${id}:`, remainingKeys); if (remainingKeys.length === 0) { require_mainThunkProcessor.debugLog("subscription", `[unsubscribe] No keys left, removing subscription ${id}`); this.subscriptions.delete(id); } else this.subscriptions.set(id, { ...sub, keys: remainingKeys }); } } /** * Notify all subscribers whose keys have changed, passing the relevant partial state. */ notify(prev, next) { require_mainThunkProcessor.debugLog("subscription", `[notify] Starting notification with ${this.subscriptions.size} subscribers`); require_mainThunkProcessor.debugLog("subscription", "[notify] States:", { prev: prev === void 0 ? "undefined" : JSON.stringify(prev), next: JSON.stringify(next) }); for (const { keys, callback, windowId } of this.subscriptions.values()) { require_mainThunkProcessor.debugLog("subscription", `[notify] Checking window ${windowId} with keys:`, keys); if (hasRelevantChange(prev, next, keys)) { const partialState = getPartialState(next, keys); require_mainThunkProcessor.debugLog("subscription", `[notify] Notifying window ${windowId} with state:`, partialState); callback(partialState, next); } else require_mainThunkProcessor.debugLog("subscription", `[notify] No relevant changes for window ${windowId} - skipping notification`); } require_mainThunkProcessor.debugLog("subscription", "[notify] Notification complete"); } /** * For debugging: get all current subscription keys for a window. * Aggregates keys across all subscriptions for the given window. * Returns: * - ['*'] for full state subscription * - [] for no subscriptions * - Array of specific keys otherwise */ getCurrentSubscriptionKeys(windowId) { require_mainThunkProcessor.debugLog("subscription", `[getCurrentSubscriptionKeys] Looking up subscriptions for window ${windowId}`); const allKeys = /* @__PURE__ */ new Set(); for (const sub of this.subscriptions.values()) { if (sub.windowId !== windowId) continue; if (sub.keys === void 0) { require_mainThunkProcessor.debugLog("subscription", `[getCurrentSubscriptionKeys] Found "*" subscription, returning ["*"]`); return ["*"]; } for (const key of sub.keys) allKeys.add(key); } if (allKeys.size === 0) { require_mainThunkProcessor.debugLog("subscription", `[getCurrentSubscriptionKeys] No subscriptions found for window ${windowId}`); return []; } const result = [...allKeys]; require_mainThunkProcessor.debugLog("subscription", "[getCurrentSubscriptionKeys] Found specific keys:", result); return result; } }; //#endregion //#region src/bridge/ipc/validation.ts /** * Validation schemas for IPC payloads using Zod. * * These schemas provide: * - Type-safe validation of all IPC inputs from renderer processes * - Protection against injection attacks and DoS via length limits * - Consistent validation across single and batch dispatch * - Better error messages for debugging */ /** * Maximum lengths for string fields to prevent DoS attacks */ const MAX_STRING_LENGTH = { ACTION_TYPE: 200, ACTION_ID: 100, BATCH_ID: 100, PARENT_ID: 100 }; /** * Maximum batch size to prevent DoS attacks. * * This is an absolute hard limit that cannot be overridden by configuration. * * Rationale for 200: * - ActionBatcher sends ~50 actions/batch in normal operation (BATCHING_DEFAULTS.maxBatchSize) * - ActionBatcher queue limit is maxBatchSize * 4 = 200 (proven safe in production) * - This limit provides 4x safety margin while preventing abuse from compromised renderers * - Normal operation never approaches this limit * - Consistent with existing architecture (4x multiplier used in ActionBatcher) * * Security model: * - Normal operation: ≤50 actions/batch (ActionBatcher controlled) * - Hard security cap: ≤200 actions/batch (Zod validation - catches malicious/buggy batches) */ const MAX_BATCH_SIZE = 200; /** * Schema for validating Action payloads from renderer * Matches the Action type from @zubridge/types but enforces runtime validation */ const ActionPayloadSchema = zod.z.object({ type: zod.z.string().min(1).max(MAX_STRING_LENGTH.ACTION_TYPE), payload: zod.z.unknown().optional(), __id: zod.z.string().max(MAX_STRING_LENGTH.ACTION_ID).optional(), __bypassAccessControl: zod.z.boolean().optional(), __immediate: zod.z.boolean().optional(), __startsThunk: zod.z.boolean().optional(), __sourceWindowId: zod.z.number().optional() }).strict(); /** * Known fields in ActionPayloadSchema - these are kept for validation. * Fields starting with __ that are NOT in this list will be stripped. */ const KNOWN_ACTION_FIELDS = new Set([ "type", "payload", "__id", "__bypassAccessControl", "__immediate", "__startsThunk", "__sourceWindowId" ]); /** * Strips internal __-prefixed fields from an action object before validation. * Only strips fields that start with __ but are NOT in the known action fields list. * Fields in the schema (like __id, __immediate) are kept for validation. * @param action - The action object to sanitize (must be a plain object) * @returns Action object with unknown internal fields removed, or original if not an object */ function stripInternalFields(action) { if (!action || typeof action !== "object" || Array.isArray(action)) return action; const result = {}; for (const [key, value] of Object.entries(action)) if (!key.startsWith("__") || KNOWN_ACTION_FIELDS.has(key)) result[key] = value; return result; } /** * Schema for single action dispatch payload * Used by handleDispatch */ const SingleDispatchPayloadSchema = zod.z.object({ action: ActionPayloadSchema, parentId: zod.z.string().max(MAX_STRING_LENGTH.PARENT_ID).optional() }).strict(); /** * Schema for individual actions within a batch */ const BatchActionItemSchema = zod.z.object({ action: ActionPayloadSchema, id: zod.z.string().min(1).max(MAX_STRING_LENGTH.ACTION_ID), parentId: zod.z.string().max(MAX_STRING_LENGTH.PARENT_ID).optional() }).strict(); /** * Schema for batch dispatch payload * Used by handleBatchDispatch */ const BatchDispatchPayloadSchema = zod.z.object({ batchId: zod.z.string().min(1).max(MAX_STRING_LENGTH.BATCH_ID), actions: zod.z.array(BatchActionItemSchema).min(1).max(MAX_BATCH_SIZE) }).strict(); /** * Validates a single action dispatch payload * @param data - Unknown data from IPC channel * @returns Validation result with typed data or error message */ function validateSingleDispatch(data) { if (data && typeof data === "object" && "action" in data) { const sanitizedData = { ...data, action: stripInternalFields(data.action) }; const result$1 = SingleDispatchPayloadSchema.safeParse(sanitizedData); if (result$1.success) return { success: true, data: result$1.data }; return { success: false, error: formatZodError(result$1.error), details: result$1.error }; } const result = SingleDispatchPayloadSchema.safeParse(data); if (result.success) return { success: true, data: result.data }; return { success: false, error: formatZodError(result.error), details: result.error }; } /** * Validates a batch dispatch payload * @param data - Unknown data from IPC channel * @returns Validation result with typed data or error message */ function validateBatchDispatch(data) { if (data && typeof data === "object" && "action