import { elementShouldHandleOwnShortcut, getCurrentPlatform, handleKeyboardEvent, KeyCommandObject, } from "@noya-app/noya-keymap"; import { AssetStoreRemote } from "../AssetManager"; import { getShortcutMenuItems } from "../menuTree"; import { ClientToServerMessage } from "../multiplayer"; import { PolicyAuthContext } from "../multiplayerPolicies"; import { createDefaultMessageHandler, EmbeddedToParentMessage, ParentToEmbeddedMessage, } from "./parentFrameMessages"; import { handleAdapterMessage } from "./syncUtils"; import { ParentFrameSyncOptions, SyncAdapter } from "./types"; function buildPolicyAuthContext( baseContext: PolicyAuthContext | undefined, userId?: string ): PolicyAuthContext | undefined { const access = baseContext?.access; const resolvedId = userId !== undefined ? userId : baseContext?.id; if (access === undefined && resolvedId === undefined) { return undefined; } const context: PolicyAuthContext = {}; if (access !== undefined) { context.access = access; } if (resolvedId !== undefined) { context.id = resolvedId; } return context; } export function parentFrameSync< S, M extends object, E extends object, MenuT extends string = string, I extends Record = Record, >(options: ParentFrameSyncOptions = {}): SyncAdapter { const { debug = false, origin = "*", messageHandler = createDefaultMessageHandler(), policyAuthContext, } = options; return ({ noyaManager }) => { if (debug) { console.info("[parentFrameSync] Initializing", noyaManager); } const embeddedPolicyContext = policyAuthContext; let lastPolicyContext: PolicyAuthContext | undefined; const applyPolicyAuthContext = (userId?: string) => { const context = buildPolicyAuthContext(embeddedPolicyContext, userId); if (!context) return; if ( lastPolicyContext && lastPolicyContext.access === context.access && lastPolicyContext.id === context.id ) { return; } lastPolicyContext = context; noyaManager.multiplayerStateManager.setPolicyAuthContext(context); }; applyPolicyAuthContext(noyaManager.userManager.currentUserId$.get()); const unsubscribePolicyAuth = noyaManager.userManager.currentUserId$.subscribe((userId) => { applyPolicyAuthContext(userId); }); // Ignore all null-object messages from parent/server. In embedded mode, // a `{ type: "object", object: null }` message is not useful and can erase // an initial state created by the child (e.g., via URL params). We still // adopt schema if provided and mark initialized for downstream logic. // Set up asset store noyaManager.assetManager.assetStore = new AssetStoreRemote( noyaManager.rpcManager ); noyaManager.resourceManager.isInitialized$.set(true); // Handle incoming messages const handleMessage = async (event: MessageEvent) => { if (debug) { console.info("[parentFrameSync] receiving message", event.data); } const data = event.data; const message = data as ParentToEmbeddedMessage; switch (message.type) { case "end": case "chunk": case "error": noyaManager.rpcManager.handleMessage(message); break; case "application.setAvoidRects": { // Store in manager observable for children to consume noyaManager.avoidRects$.set(message.payload.rects); break; } case "application.selectMenuItem": noyaManager.menuManager.emit(message.payload.value as MenuT); break; case "connection": noyaManager.connectionEventManager.addEvent({ type: "stateChange", state: message.state === "connected" ? "OPEN" : "CLOSED", }); break; case "ai.tool.call": { const response = await noyaManager.aiManager.callTool( message.payload ); const responseMessage: EmbeddedToParentMessage = { type: "ai.tool.response", payload: { invocation: message.payload, response }, }; messageHandler.postMessage(responseMessage, origin); break; } case "application.evaluate.result": { noyaManager.evalManager.responseEmitter.emit(message.payload); break; } case "application.requestPublish": { if (!noyaManager.publishingManager.getFilesToPublish) break; const files = await noyaManager.publishingManager.getFilesToPublish(); if (!files) break; const publishMessage: EmbeddedToParentMessage = { type: "application.publish", payload: { id: message.payload.id, files }, }; messageHandler.postMessage(publishMessage, origin); break; } default: { if (message.type === "object" && message.object === null) { console.log("null object message", message); // Preserve child's current state, but adopt schema and mark initialized // const schema = message.schema // ? decodeSchema(message.schema) // : noyaManager.multiplayerStateManager.schema; // noyaManager.multiplayerStateManager._setState( // noyaManager.multiplayerStateManager.state, // schema // ); // noyaManager.multiplayerStateManager.isInitialized$.set(true); // Log receive for visibility noyaManager.connectionEventManager.addEvent({ type: "receive", message, }); break; } handleAdapterMessage(message, noyaManager); if (message.type === "connectedUsers") { const currentUserId = noyaManager.userManager.currentUserId$.get(); const currentClientId = noyaManager.userManager.currentClientId$.get(); const self = message.users.find( (user) => (currentUserId !== undefined && user.authId === currentUserId) || (currentClientId !== undefined && user.clientId === currentClientId) ); if (self) { noyaManager.userManager.setCurrentConnectionId(self.connectionId); noyaManager.sharedConnectionDataManager.setCurrentConnectionId( self.connectionId ); } } noyaManager.multiplayerStateManager.incomingMessages.push(message); noyaManager.multiplayerStateManager.processIncomingMessages(); noyaManager.connectionEventManager.addEvent({ type: "receive", message, }); break; } } }; const unsubscribeEvalResult = noyaManager.evalManager.requestEmitter.addListener((evalRequest) => { const resultMessage: EmbeddedToParentMessage = { type: "application.evaluate", payload: evalRequest, }; window.parent?.postMessage(resultMessage, origin); }); const unsubscribeLeftMenuItems = noyaManager.menuManager.leftMenuItems$.subscribe((menuItems) => { const message: EmbeddedToParentMessage = { type: "application.setMenuItems", payload: { menuItems, position: "left" }, }; if (debug) { console.info("[parentFrameSync] sending left menu items", message); } window.parent?.postMessage(message, "*"); }); const unsubscribeShowCommandPalette = noyaManager.menuManager.showCommandPaletteEmitter.addListener((show) => { const message: EmbeddedToParentMessage = { type: "application.showCommandPalette", payload: show, }; window.parent?.postMessage(message, "*"); }); const unsubscribeRightMenuItems = noyaManager.menuManager.rightMenuItems$.subscribe((menuItems) => { const message: EmbeddedToParentMessage = { type: "application.setMenuItems", payload: { menuItems, position: "right" }, }; if (debug) { console.info("[parentFrameSync] sending right menu items", message); } window.parent?.postMessage(message, "*"); }); const shortcutHandler = (event: KeyboardEvent) => { const shortcuts = getShortcutMenuItems( noyaManager.menuManager.allMenuItems$.get() ).map((item): [string, KeyCommandObject] => { const keyCommand: KeyCommandObject = { command: () => noyaManager.menuManager.emit(item.value as MenuT), // allowInInput: item.role === "undo" || item.role === "redo", }; return [item.shortcut, keyCommand]; }); const invokedWithinInput = elementShouldHandleOwnShortcut(event.target); handleKeyboardEvent(event, getCurrentPlatform(navigator), shortcuts, { invokedWithinInput, }); }; window.addEventListener("keydown", shortcutHandler); const unsubscribeShortcutHandler = () => { window.removeEventListener("keydown", shortcutHandler); }; // Send ready message to parent const readyMessage: EmbeddedToParentMessage = { type: "multiplayer.ready", }; messageHandler.postMessage(readyMessage, origin); // Listen for messages from parent const removeMessageListener = messageHandler.addMessageListener(handleMessage); // Subscribe to outgoing messages const unsubscribeMultiplayerMessage = noyaManager.multiplayerStateManager.messageEmitter.addListener( (message: ClientToServerMessage) => { if (debug) { console.info("[parentFrameSync] sending message", message); } noyaManager.connectionEventManager.addEvent({ type: "send", message, }); const parentMessage: EmbeddedToParentMessage = { type: "multiplayer.message", payload: message, }; messageHandler.postMessage(parentMessage, origin); } ); // Subscribe to RPC requests const unsubscribeRpc = noyaManager.rpcManager.addListener( async (request) => { if (!request.id) { console.error("[parentFrameSync] RPC request has no id", request); return; } const rpcMessage: EmbeddedToParentMessage = { type: "rpc.request", payload: request, }; if (debug) { console.info("[parentFrameSync] sending rpc request", rpcMessage); } messageHandler.postMessage(rpcMessage, origin); } ); const unsubscribeAiSetTools = noyaManager.aiManager.configuration$.subscribe( ({ tools, systemMessage }) => { const message: EmbeddedToParentMessage = { type: "ai.setConfig", payload: { tools, systemMessage }, }; messageHandler.postMessage(message, origin); } ); const unsubscribeEnablePublishing = noyaManager.publishingManager.enablePublishing$.subscribe((enabled) => { const message: EmbeddedToParentMessage = { type: "application.enablePublishing", payload: enabled, }; messageHandler.postMessage(message, origin); }); const unsubscribeSharedConnectionData = noyaManager.sharedConnectionDataManager.currentConnectionDataEmitter.addListener( (sharedConnectionData: E | undefined) => { const connectionId = noyaManager.sharedConnectionDataManager.currentConnectionId$.get(); if (!connectionId) return; const message: EmbeddedToParentMessage = { type: "multiplayer.message", payload: { type: "setSharedConnectionData", connectionId, data: sharedConnectionData, }, }; messageHandler.postMessage(message, origin); } ); // Subscribe to connection state changes const unsubscribeError = noyaManager.multiplayerStateManager.errorEmitter.addListener((error) => { noyaManager.connectionEventManager.addEvent({ type: "error", error }); }); noyaManager.multiplayerStateManager.connect(); noyaManager.rpcManager.setIsConnected(true); // Return cleanup function return () => { if (debug) { console.info("[parentFrameSync] Cleaning up"); } removeMessageListener(); unsubscribeLeftMenuItems(); unsubscribeRightMenuItems(); unsubscribeShowCommandPalette(); unsubscribeShortcutHandler(); unsubscribeMultiplayerMessage(); unsubscribeRpc(); unsubscribeError(); unsubscribeAiSetTools(); unsubscribeEvalResult(); unsubscribeEnablePublishing(); unsubscribeSharedConnectionData(); unsubscribePolicyAuth(); noyaManager.multiplayerStateManager.disconnect(); noyaManager.rpcManager.setIsConnected(false); }; }; }