diff --git a/docs/maps/api-room.md b/docs/maps/api-room.md index 17e3d48e..9d08ce1b 100644 --- a/docs/maps/api-room.md +++ b/docs/maps/api-room.md @@ -81,7 +81,7 @@ WA.room.getCurrentRoom(): Promise ``` Return a promise that resolves to a `Room` object with the following attributes : * **id (string) :** ID of the current room -* **map (ITiledMap) :** contains the JSON map file with the properties that were setted by the script if `setProperty` was called. +* **map (ITiledMap) :** contains the JSON map file with the properties that were set by the script if `setProperty` was called. * **mapUrl (string) :** Url of the JSON map file * **startLayer (string | null) :** Name of the layer where the current user started, only if different from `start` layer diff --git a/front/src/Api/Events/IframeEvent.ts b/front/src/Api/Events/IframeEvent.ts index 67775c03..fc3384f8 100644 --- a/front/src/Api/Events/IframeEvent.ts +++ b/front/src/Api/Events/IframeEvent.ts @@ -23,6 +23,9 @@ export interface TypedMessageEvent extends MessageEvent { data: T; } +/** + * List event types sent from an iFrame to WorkAdventure + */ export type IframeEventMap = { loadPage: LoadPageEvent; chat: ChatEvent; @@ -62,7 +65,6 @@ export interface IframeResponseEventMap { enterEvent: EnterLeaveEvent; leaveEvent: EnterLeaveEvent; buttonClickedEvent: ButtonClickedEvent; - gameState: GameStateEvent; hasPlayerMoved: HasPlayerMovedEvent; dataLayer: DataLayerEvent; menuItemClicked: MenuItemClickedEvent; @@ -76,3 +78,46 @@ export interface IframeResponseEvent { export const isIframeResponseEventWrapper = (event: { type?: string; }): event is IframeResponseEvent => typeof event.type === "string"; + + +/** + * List event types sent from an iFrame to WorkAdventure that expect a unique answer from WorkAdventure along the type for the answer from WorkAdventure to the iFrame + */ +export type IframeQueryMap = { + getState: { + query: undefined, + answer: GameStateEvent + }, +} + +export interface IframeQuery { + type: T; + data: IframeQueryMap[T]['query']; +} + +export interface IframeQueryWrapper { + id: number; + query: IframeQuery; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isIframeQuery = (event: any): event is IframeQuery => typeof event.type === 'string'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isIframeQueryWrapper = (event: any): event is IframeQueryWrapper => typeof event.id === 'number' && isIframeQuery(event.query); + +export interface IframeAnswerEvent { + id: number; + type: T; + data: IframeQueryMap[T]['answer']; +} + +export const isIframeAnswerEvent = (event: { type?: string, id?: number }): event is IframeAnswerEvent => typeof event.type === 'string' && typeof event.id === 'number'; + +export interface IframeErrorAnswerEvent { + id: number; + type: keyof IframeQueryMap; + error: string; +} + +export const isIframeErrorAnswerEvent = (event: { type?: string, id?: number, error?: string }): event is IframeErrorAnswerEvent => typeof event.type === 'string' && typeof event.id === 'number' && typeof event.error === 'string'; diff --git a/front/src/Api/IframeListener.ts b/front/src/Api/IframeListener.ts index b97fc567..314d5d2e 100644 --- a/front/src/Api/IframeListener.ts +++ b/front/src/Api/IframeListener.ts @@ -10,11 +10,13 @@ import { scriptUtils } from "./ScriptUtils"; import { GoToPageEvent, isGoToPageEvent } from "./Events/GoToPageEvent"; import { isOpenCoWebsite, OpenCoWebSiteEvent } from "./Events/OpenCoWebSiteEvent"; import { + IframeErrorAnswerEvent, IframeEvent, - IframeEventMap, + IframeEventMap, IframeQueryMap, IframeResponseEvent, IframeResponseEventMap, isIframeEventWrapper, + isIframeQueryWrapper, TypedMessageEvent, } from "./Events/IframeEvent"; import type { UserInputChatEvent } from "./Events/UserInputChatEvent"; @@ -31,6 +33,8 @@ import { isLoadPageEvent } from "./Events/LoadPageEvent"; import { handleMenuItemRegistrationEvent, isMenuItemRegisterIframeEvent } from "./Events/ui/MenuItemRegisterEvent"; import { SetTilesEvent, isSetTilesEvent } from "./Events/SetTilesEvent"; +type AnswererCallback = (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|Promise; + /** * Listens to messages from iframes and turn those messages into easy to use observables. * Also allows to send messages to those iframes. @@ -81,9 +85,6 @@ class IframeListener { private readonly _setPropertyStream: Subject = new Subject(); public readonly setPropertyStream = this._setPropertyStream.asObservable(); - private readonly _gameStateStream: Subject = new Subject(); - public readonly gameStateStream = this._gameStateStream.asObservable(); - private readonly _dataLayerChangeStream: Subject = new Subject(); public readonly dataLayerChangeStream = this._dataLayerChangeStream.asObservable(); @@ -110,6 +111,10 @@ class IframeListener { private readonly scripts = new Map(); private sendPlayerMove: boolean = false; + private answerers: { + [key in keyof IframeQueryMap]?: AnswererCallback + } = {}; + init() { window.addEventListener( "message", @@ -119,7 +124,7 @@ class IframeListener { // Note: maybe we could restrict on the domain too for additional security (in case the iframe goes to another domain). let foundSrc: string | undefined; - let iframe: HTMLIFrameElement; + let iframe: HTMLIFrameElement | undefined; for (iframe of this.iframes) { if (iframe.contentWindow === message.source) { foundSrc = iframe.src; @@ -129,7 +134,7 @@ class IframeListener { const payload = message.data; - if (foundSrc === undefined) { + if (foundSrc === undefined || iframe === undefined) { if (isIframeEventWrapper(payload)) { console.warn( "It seems an iFrame is trying to communicate with WorkAdventure but was not explicitly granted the permission to do so. " + @@ -143,65 +148,101 @@ class IframeListener { foundSrc = this.getBaseUrl(foundSrc, message.source); - if (isIframeEventWrapper(payload)) { - if (payload.type === "showLayer" && isLayerEvent(payload.data)) { - this._showLayerStream.next(payload.data); - } else if (payload.type === "hideLayer" && isLayerEvent(payload.data)) { - this._hideLayerStream.next(payload.data); - } else if (payload.type === "setProperty" && isSetPropertyEvent(payload.data)) { - this._setPropertyStream.next(payload.data); - } else if (payload.type === "chat" && isChatEvent(payload.data)) { - this._chatStream.next(payload.data); - } else if (payload.type === "openPopup" && isOpenPopupEvent(payload.data)) { - this._openPopupStream.next(payload.data); - } else if (payload.type === "closePopup" && isClosePopupEvent(payload.data)) { - this._closePopupStream.next(payload.data); - } else if (payload.type === "openTab" && isOpenTabEvent(payload.data)) { - scriptUtils.openTab(payload.data.url); - } else if (payload.type === "goToPage" && isGoToPageEvent(payload.data)) { - scriptUtils.goToPage(payload.data.url); - } else if (payload.type === "loadPage" && isLoadPageEvent(payload.data)) { - this._loadPageStream.next(payload.data.url); - } else if (payload.type === "playSound" && isPlaySoundEvent(payload.data)) { - this._playSoundStream.next(payload.data); - } else if (payload.type === "stopSound" && isStopSoundEvent(payload.data)) { - this._stopSoundStream.next(payload.data); - } else if (payload.type === "loadSound" && isLoadSoundEvent(payload.data)) { - this._loadSoundStream.next(payload.data); - } else if (payload.type === "openCoWebSite" && isOpenCoWebsite(payload.data)) { - scriptUtils.openCoWebsite( - payload.data.url, - foundSrc, - payload.data.allowApi, - payload.data.allowPolicy - ); - } else if (payload.type === "closeCoWebSite") { - scriptUtils.closeCoWebSite(); - } else if (payload.type === "disablePlayerControls") { - this._disablePlayerControlStream.next(); - } else if (payload.type === "restorePlayerControls") { - this._enablePlayerControlStream.next(); - } else if (payload.type === "displayBubble") { - this._displayBubbleStream.next(); - } else if (payload.type === "removeBubble") { - this._removeBubbleStream.next(); - } else if (payload.type == "getState") { - this._gameStateStream.next(); - } else if (payload.type == "onPlayerMove") { - this.sendPlayerMove = true; - } else if (payload.type == "getDataLayer") { - this._dataLayerChangeStream.next(); - } else if (isMenuItemRegisterIframeEvent(payload)) { - const data = payload.data.menutItem; - // @ts-ignore - this.iframeCloseCallbacks.get(iframe).push(() => { - this._unregisterMenuCommandStream.next(data); - }); - handleMenuItemRegistrationEvent(payload.data); - } else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) { - this._setTilesStream.next(payload.data); + if (isIframeQueryWrapper(payload)) { + const queryId = payload.id; + const query = payload.query; + + const answerer = this.answerers[query.type]; + if (answerer === undefined) { + const errorMsg = 'The iFrame sent a message of type "'+query.type+'" but there is no service configured to answer these messages.'; + console.error(errorMsg); + iframe.contentWindow?.postMessage({ + id: queryId, + type: query.type, + error: errorMsg + } as IframeErrorAnswerEvent, '*'); + return; + } + + Promise.resolve(answerer(query.data)).then((value) => { + iframe?.contentWindow?.postMessage({ + id: queryId, + type: query.type, + data: value + }, '*'); + }).catch(reason => { + console.error('An error occurred while responding to an iFrame query.', reason); + let reasonMsg: string; + if (reason instanceof Error) { + reasonMsg = reason.message; + } else { + reasonMsg = reason.toString(); + } + + iframe?.contentWindow?.postMessage({ + id: queryId, + type: query.type, + error: reasonMsg + } as IframeErrorAnswerEvent, '*'); + }); + + } else if (isIframeEventWrapper(payload)) { + if (payload.type === "showLayer" && isLayerEvent(payload.data)) { + this._showLayerStream.next(payload.data); + } else if (payload.type === "hideLayer" && isLayerEvent(payload.data)) { + this._hideLayerStream.next(payload.data); + } else if (payload.type === "setProperty" && isSetPropertyEvent(payload.data)) { + this._setPropertyStream.next(payload.data); + } else if (payload.type === "chat" && isChatEvent(payload.data)) { + this._chatStream.next(payload.data); + } else if (payload.type === "openPopup" && isOpenPopupEvent(payload.data)) { + this._openPopupStream.next(payload.data); + } else if (payload.type === "closePopup" && isClosePopupEvent(payload.data)) { + this._closePopupStream.next(payload.data); + } else if (payload.type === "openTab" && isOpenTabEvent(payload.data)) { + scriptUtils.openTab(payload.data.url); + } else if (payload.type === "goToPage" && isGoToPageEvent(payload.data)) { + scriptUtils.goToPage(payload.data.url); + } else if (payload.type === "loadPage" && isLoadPageEvent(payload.data)) { + this._loadPageStream.next(payload.data.url); + } else if (payload.type === "playSound" && isPlaySoundEvent(payload.data)) { + this._playSoundStream.next(payload.data); + } else if (payload.type === "stopSound" && isStopSoundEvent(payload.data)) { + this._stopSoundStream.next(payload.data); + } else if (payload.type === "loadSound" && isLoadSoundEvent(payload.data)) { + this._loadSoundStream.next(payload.data); + } else if (payload.type === "openCoWebSite" && isOpenCoWebsite(payload.data)) { + scriptUtils.openCoWebsite( + payload.data.url, + foundSrc, + payload.data.allowApi, + payload.data.allowPolicy + ); + } else if (payload.type === "closeCoWebSite") { + scriptUtils.closeCoWebSite(); + } else if (payload.type === "disablePlayerControls") { + this._disablePlayerControlStream.next(); + } else if (payload.type === "restorePlayerControls") { + this._enablePlayerControlStream.next(); + } else if (payload.type === "displayBubble") { + this._displayBubbleStream.next(); + } else if (payload.type === "removeBubble") { + this._removeBubbleStream.next(); + } else if (payload.type == "onPlayerMove") { + this.sendPlayerMove = true; + } else if (payload.type == "getDataLayer") { + this._dataLayerChangeStream.next(); + } else if (isMenuItemRegisterIframeEvent(payload)) { + const data = payload.data.menutItem; + // @ts-ignore + this.iframeCloseCallbacks.get(iframe).push(() => { + this._unregisterMenuCommandStream.next(data); + }); + handleMenuItemRegistrationEvent(payload.data); + } else if (payload.type == "setTiles" && isSetTilesEvent(payload.data)) { + this._setTilesStream.next(payload.data); + } } - } }, false ); @@ -214,13 +255,6 @@ class IframeListener { }); } - sendGameStateEvent(gameStateEvent: GameStateEvent) { - this.postMessage({ - type: "gameState", - data: gameStateEvent, - }); - } - /** * Allows the passed iFrame to send/receive messages via the API. */ @@ -368,6 +402,22 @@ class IframeListener { iframe.contentWindow?.postMessage(message, "*"); } } + + /** + * Registers a callback that can be used to respond to some query (as defined in the IframeQueryMap type). + * + * Important! There can be only one "answerer" so registering a new one will unregister the old one. + * + * @param key The "type" of the query we are answering + * @param callback + */ + public registerAnswerer(key: T, callback: (query: IframeQueryMap[T]['query']) => IframeQueryMap[T]['answer']|Promise ): void { + this.answerers[key] = callback; + } + + public unregisterAnswerer(key: keyof IframeQueryMap): void { + delete this.answerers[key]; + } } export const iframeListener = new IframeListener(); diff --git a/front/src/Api/iframe/IframeApiContribution.ts b/front/src/Api/iframe/IframeApiContribution.ts index f3b25999..e4ba089e 100644 --- a/front/src/Api/iframe/IframeApiContribution.ts +++ b/front/src/Api/iframe/IframeApiContribution.ts @@ -1,9 +1,40 @@ import type * as tg from "generic-type-guard"; -import type { IframeEvent, IframeEventMap, IframeResponseEventMap } from '../Events/IframeEvent'; +import type { + IframeEvent, + IframeEventMap, IframeQuery, + IframeQueryMap, + IframeResponseEventMap +} from '../Events/IframeEvent'; +import type {IframeQueryWrapper} from "../Events/IframeEvent"; export function sendToWorkadventure(content: IframeEvent) { window.parent.postMessage(content, "*") } + +let queryNumber = 0; + +export const answerPromises = new Map)) => void, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + reject: (reason?: any) => void +}>(); + +export function queryWorkadventure(content: IframeQuery): Promise { + return new Promise((resolve, reject) => { + window.parent.postMessage({ + id: queryNumber, + query: content + } as IframeQueryWrapper, "*"); + + answerPromises.set(queryNumber, { + resolve, + reject + }); + + queryNumber++; + }); +} + type GuardedType> = Guard extends tg.TypeGuard ? T : never export interface IframeCallback> { diff --git a/front/src/Api/iframe/room.ts b/front/src/Api/iframe/room.ts index 78fad58b..c70d0aad 100644 --- a/front/src/Api/iframe/room.ts +++ b/front/src/Api/iframe/room.ts @@ -4,7 +4,7 @@ import { isDataLayerEvent } from "../Events/DataLayerEvent"; import { EnterLeaveEvent, isEnterLeaveEvent } from "../Events/EnterLeaveEvent"; import { isGameStateEvent } from "../Events/GameStateEvent"; -import { IframeApiContribution, sendToWorkadventure } from "./IframeApiContribution"; +import {IframeApiContribution, queryWorkadventure, sendToWorkadventure} from "./IframeApiContribution"; import { apiCallback } from "./registeredCallbacks"; import type { ITiledMap } from "../../Phaser/Map/ITiledMap"; @@ -16,7 +16,7 @@ const leaveStreams: Map> = new Map(); const stateResolvers = new Subject(); -let immutableData: GameStateEvent; +let immutableDataPromise: Promise | undefined = undefined; interface Room { id: string; @@ -39,14 +39,10 @@ interface TileDescriptor { } function getGameState(): Promise { - if (immutableData) { - return Promise.resolve(immutableData); - } else { - return new Promise((resolver, thrower) => { - stateResolvers.subscribe(resolver); - sendToWorkadventure({ type: "getState", data: null }); - }); + if (immutableDataPromise === undefined) { + immutableDataPromise = queryWorkadventure({ type: "getState", data: undefined }); } + return immutableDataPromise; } function getDataLayer(): Promise { @@ -72,13 +68,6 @@ export class WorkadventureRoomCommands extends IframeApiContribution { - stateResolvers.next(payloadData); - }, - }), apiCallback({ type: "dataLayer", typeChecker: isDataLayerEvent, diff --git a/front/src/Phaser/Components/TextUtils.ts b/front/src/Phaser/Components/TextUtils.ts index db9a97fb..972c50c7 100644 --- a/front/src/Phaser/Components/TextUtils.ts +++ b/front/src/Phaser/Components/TextUtils.ts @@ -44,7 +44,6 @@ export class TextUtils { options.align = object.text.halign; } - console.warn(options); const textElem = scene.add.text(object.x, object.y, object.text.text, options); textElem.setAngle(object.rotation); } diff --git a/front/src/Phaser/Game/GameScene.ts b/front/src/Phaser/Game/GameScene.ts index d137c9e0..d767f0f4 100644 --- a/front/src/Phaser/Game/GameScene.ts +++ b/front/src/Phaser/Game/GameScene.ts @@ -1044,18 +1044,16 @@ ${escapedMessage} }) ); - this.iframeSubscriptionList.push( - iframeListener.gameStateStream.subscribe(() => { - iframeListener.sendGameStateEvent({ - mapUrl: this.MapUrlFile, - startLayerName: this.startPositionCalculator.startLayerName, - uuid: localUserStore.getLocalUser()?.uuid, - nickname: localUserStore.getName(), - roomId: this.RoomId, - tags: this.connection ? this.connection.getAllTags() : [], - }); - }) - ); + iframeListener.registerAnswerer('getState', () => { + return { + mapUrl: this.MapUrlFile, + startLayerName: this.startPositionCalculator.startLayerName, + uuid: localUserStore.getLocalUser()?.uuid, + nickname: localUserStore.getName(), + roomId: this.RoomId, + tags: this.connection ? this.connection.getAllTags() : [], + }; + }); this.iframeSubscriptionList.push( iframeListener.setTilesStream.subscribe((eventTiles) => { for (const eventTile of eventTiles) { @@ -1149,6 +1147,7 @@ ${escapedMessage} this.emoteManager.destroy(); this.peerStoreUnsubscribe(); this.biggestAvailableAreaStoreUnsubscribe(); + iframeListener.unregisterAnswerer('getState'); mediaManager.hideGameOverlay(); diff --git a/front/src/iframe_api.ts b/front/src/iframe_api.ts index a1949106..1915020e 100644 --- a/front/src/iframe_api.ts +++ b/front/src/iframe_api.ts @@ -1,7 +1,7 @@ import { registeredCallbacks } from "./Api/iframe/registeredCallbacks"; import { IframeResponseEvent, - IframeResponseEventMap, + IframeResponseEventMap, isIframeAnswerEvent, isIframeErrorAnswerEvent, isIframeResponseEventWrapper, TypedMessageEvent, } from "./Api/Events/IframeEvent"; @@ -16,7 +16,7 @@ import player from "./Api/iframe/player"; import type { ButtonDescriptor } from "./Api/iframe/Ui/ButtonDescriptor"; import type { Popup } from "./Api/iframe/Ui/Popup"; import type { Sound } from "./Api/iframe/Sound/Sound"; -import { sendToWorkadventure } from "./Api/iframe/IframeApiContribution"; +import { answerPromises, sendToWorkadventure} from "./Api/iframe/IframeApiContribution"; const wa = { ui, @@ -164,16 +164,38 @@ declare global { window.WA = wa; window.addEventListener( - "message", - (message: TypedMessageEvent>) => { - if (message.source !== window.parent) { - return; // Skip message in this event listener - } - const payload = message.data; - console.debug(payload); + "message", (message: TypedMessageEvent>) => { + if (message.source !== window.parent) { + return; // Skip message in this event listener + } + const payload = message.data; - if (isIframeResponseEventWrapper(payload)) { - const payloadData = payload.data; + console.debug(payload); + + if (isIframeAnswerEvent(payload)) { + const queryId = payload.id; + const payloadData = payload.data; + + const resolver = answerPromises.get(queryId); + if (resolver === undefined) { + throw new Error('In Iframe API, got an answer for a question that we have no track of.'); + } + resolver.resolve(payloadData); + + answerPromises.delete(queryId); + } else if (isIframeErrorAnswerEvent(payload)) { + const queryId = payload.id; + const payloadError = payload.error; + + const resolver = answerPromises.get(queryId); + if (resolver === undefined) { + throw new Error('In Iframe API, got an error answer for a question that we have no track of.'); + } + resolver.reject(payloadError); + + answerPromises.delete(queryId); + } else if (isIframeResponseEventWrapper(payload)) { + const payloadData = payload.data; const callback = registeredCallbacks[payload.type] as IframeCallback | undefined; if (callback?.typeChecker(payloadData)) {