namespace gdjs { declare var cordova: any; declare var SafariViewController: any; const logger = new gdjs.Logger('Player Authentication'); const authComponents = gdjs.playerAuthenticationComponents; /** @category Other extensions > Player Authentication */ export type PlayerAuthenticationPlatform = | 'electron' | 'cordova-websocket' | 'web-iframe' | 'web' | 'games-platform'; // TODO EBO Replace runtimeScene to instanceContainer. /** @category Other extensions > Player Authentication */ export namespace playerAuthentication { // Authentication information. let _username: string | null = null; let _userId: string | null = null; let _userToken: string | null = null; let _justLoggedIn = false; let _checkedLocalStorage: boolean = false; // Authentication display let _authenticationWindow: Window | null = null; // For Web. let _authenticationRootContainer: HTMLDivElement | null = null; let _authenticationLoaderContainer: HTMLDivElement | null = null; let _authenticationIframeContainer: HTMLDivElement | null = null; let _authenticationTextContainer: HTMLDivElement | null = null; let _authenticationBanner: HTMLDivElement | null = null; let _automaticGamesPlatformAuthenticationTimeoutId: NodeJS.Timeout | null = null; let _authenticationTimeoutId: NodeJS.Timeout | null = null; // Communication methods. let _automaticGamesPlatformAuthenticationCallback: | ((event: MessageEvent) => void) | null = null; let _authenticationMessageCallback: ((event: MessageEvent) => void) | null = null; let _websocket: WebSocket | null = null; type AuthenticationWindowStatus = 'logged' | 'errored' | 'dismissed'; type AuthenticationWindowOptions = { disableGuestLogin: boolean }; const notifyParentWindowThatPlayerAuthIsReady = ( runtimeScene: gdjs.RuntimeScene ) => { if (getPlayerAuthPlatform(runtimeScene) !== 'games-platform') { // Automatic authentication is only valid when the game is hosted on GDevelop games platform. return; } logger.info( 'Notifying parent window that player authentication is ready.' ); window.parent.postMessage( { id: 'playerAuthReady', }, '*' // We could restrict to GDevelop games platform but it's not necessary as the message is not sensitive, and it allows easy debugging. ); // If no answer after 3 seconds, assume that the game is not embedded in GDevelop games platform, and remove the listener. _automaticGamesPlatformAuthenticationTimeoutId = setTimeout(() => { logger.info( 'Removing automatic games platform authentication listener.' ); removeAutomaticGamesPlatformAuthenticationCallback(); }, 3000); }; const handleAutomaticGamesPlatformAuthentication = ( runtimeScene: gdjs.RuntimeScene ) => { if (getPlayerAuthPlatform(runtimeScene) !== 'games-platform') { // Automatic authentication is only valid when the game is hosted on GDevelop games platform. return; } removeAutomaticGamesPlatformAuthenticationCallback(); // Remove any callback that could have been registered before. _automaticGamesPlatformAuthenticationCallback = (event: MessageEvent) => { receiveAuthenticationMessage({ runtimeScene, event, checkOrigin: true, }); }; window.addEventListener( 'message', _automaticGamesPlatformAuthenticationCallback, true ); }; const handleAutomaticPreviewAuthentication = ( runtimeScene: gdjs.RuntimeScene ) => { const runtimeGameOptions = runtimeScene.getGame().getAdditionalOptions(); if (runtimeGameOptions && runtimeGameOptions.isPreview) { // If the game is a preview, and the user is already authenticated, we can log them in automatically. const playerId = runtimeGameOptions.playerId; const playerToken = runtimeGameOptions.playerToken; const playerUsername = runtimeGameOptions.playerUsername; if (playerId && playerToken) { logger.info( `Automatically logging in the player with ID ${playerId} as it's a preview.` ); saveAuthKeyToStorage({ userId: playerId, username: playerUsername || null, userToken: playerToken, }); refreshAuthenticationBannerIfAny(runtimeScene); } } }; // Ensure that the condition "just logged in" is valid only for one frame. gdjs.registerRuntimeScenePostEventsCallback(() => { _justLoggedIn = false; }); // If the extension is used, register an eventlistener to know if the user is // logged in while playing the game on GDevelop games platform. // Then send a message to the parent iframe to say that the player auth is ready. gdjs.registerFirstRuntimeSceneLoadedCallback( (runtimeScene: RuntimeScene) => { handleAutomaticPreviewAuthentication(runtimeScene); handleAutomaticGamesPlatformAuthentication(runtimeScene); notifyParentWindowThatPlayerAuthIsReady(runtimeScene); } ); const getLocalStorageKey = (gameId: string) => `${gameId}_authenticatedUser`; const getAuthWindowUrl = ({ runtimeGame, gameId, connectionId, authWindowOptions, }: { runtimeGame: gdjs.RuntimeGame; gameId: string; connectionId?: string; authWindowOptions?: AuthenticationWindowOptions; }) => { // Uncomment to test the case of a failing loading: // return 'https://gd.games.wronglink'; const baseUrl = 'https://gd.games'; // const baseUrl = 'http://localhost:4000'; const searchParams = new URLSearchParams(); searchParams.set('gameId', gameId); if (connectionId) searchParams.set('connectionId', connectionId); if (runtimeGame.isUsingGDevelopDevelopmentEnvironment()) { searchParams.set('dev', 'true'); } searchParams.set('allowLoginProviders', 'true'); if (authWindowOptions) { for (const [key, value] of Object.entries(authWindowOptions)) { searchParams.set(key, value.toString()); } } return `${baseUrl}/auth?${searchParams.toString()}`; }; /** * Get the platform running the game, which changes how the authentication * window is opened. */ const getPlayerAuthPlatform = ( runtimeScene: RuntimeScene ): PlayerAuthenticationPlatform => { const runtimeGame = runtimeScene.getGame(); const electron = runtimeGame.getRenderer().getElectron(); if (electron) { // This can be a: // - Preview in GDevelop desktop app. // - Desktop game running on Electron. return 'electron'; } // This can be a: // - Preview in GDevelop mobile app. if (shouldAuthenticationUseIframe(runtimeScene)) return 'web-iframe'; if (typeof cordova !== 'undefined') { // The game is an Android or an iOS app. return 'cordova-websocket'; } // This can be a: // - Game hosted on GDevelop games platform (gd.games) if ( window.parent !== window && (document.referrer.indexOf('https://gd.games') === 0 || document.referrer.indexOf('http://localhost:4000') === 0) ) { return 'games-platform'; } // This can be a: // - Preview in GDevelop web-app // - Preview in Gdevelop mobile app (Android only) // - Web game (any website/server other than GDevelop's game-platform) accessed via a desktop browser... // - Or a web game accessed via a mobile browser (Android/iOS). return 'web'; }; /** * Check if, in some exceptional cases, we allow authentication * to be done through a iframe. * This is usually discouraged as the user can't verify that the authentication * window is a genuine one. It's only to be used in trusted contexts (e.g: * preview in the GDevelop mobile app). */ const shouldAuthenticationUseIframe = (runtimeScene: RuntimeScene) => { const runtimeGameOptions = runtimeScene.getGame().getAdditionalOptions(); return ( runtimeGameOptions && runtimeGameOptions.isPreview && runtimeGameOptions.allowAuthenticationUsingIframeForPreview ); }; /** * Returns true if a user token is present in the local storage. */ export const isAuthenticated = () => { if (!_checkedLocalStorage) { readAuthenticatedUserFromLocalStorage(); } return _userToken !== null; }; /** * Returns true if the user just logged in. * Useful to update username or trigger messages in the game. */ export const hasLoggedIn = () => _justLoggedIn; /** * Returns the username from the local storage. */ export const getUsername = () => { if (!_checkedLocalStorage) { readAuthenticatedUserFromLocalStorage(); } return _username || ''; }; /** * Returns the user token from the local storage. */ export const getUserToken = () => { if (!_checkedLocalStorage) { readAuthenticatedUserFromLocalStorage(); } return _userToken || null; }; /** * Returns the username from the local storage. */ export const getUserId = () => { if (!_checkedLocalStorage) { readAuthenticatedUserFromLocalStorage(); } return _userId || ''; }; /** * Returns true if the game is registered, false otherwise. * Useful to display a message to the user to register the game before logging in. */ const checkIfGameIsRegistered = ( runtimeGame: gdjs.RuntimeGame, gameId: string, tries: number = 0 ): Promise => { const rootApi = runtimeGame.isUsingGDevelopDevelopmentEnvironment() ? 'https://api-dev.gdevelop.io' : 'https://api.gdevelop.io'; const url = `${rootApi}/game/public-game/${gameId}`; return fetch(url, { method: 'HEAD' }).then( (response) => { if (response.status !== 200) { logger.warn( `Error while fetching the game: ${response.status} ${response.statusText}` ); // If the response is not 404, it may be a timeout, so retry a few times. if (response.status === 404 || tries > 2) { return false; } return checkIfGameIsRegistered(runtimeGame, gameId, tries + 1); } return true; }, (err) => { logger.error('Error while fetching game:', err); return false; } ); }; /** * Remove the user information from the local storage. */ export const logout = (runtimeScene: RuntimeScene) => { _username = null; _userToken = null; _userId = null; const gameId = gdjs.projectData.properties.projectUuid; if (!gameId) { logger.error('Missing game id in project properties.'); return; } window.localStorage.removeItem(getLocalStorageKey(gameId)); cleanUpAuthWindowAndTimeouts(runtimeScene); removeAuthenticationBanner(runtimeScene); const domElementContainer = runtimeScene .getGame() .getRenderer() .getDomElementContainer(); if (!domElementContainer) { handleAuthenticationError( runtimeScene, "The div element covering the game couldn't be found, the authentication banner cannot be displayed." ); return; } authComponents.displayLoggedOutNotification(domElementContainer); }; /** * Retrieves the user information from the local storage, and store * them in the extension variables. */ const readAuthenticatedUserFromLocalStorage = () => { const gameId = gdjs.projectData.properties.projectUuid; if (!gameId) { logger.error('Missing game id in project properties.'); return; } try { const authenticatedUserStorageItem = window.localStorage.getItem( getLocalStorageKey(gameId) ); if (!authenticatedUserStorageItem) { _checkedLocalStorage = true; return; } const authenticatedUser = JSON.parse(authenticatedUserStorageItem); _username = authenticatedUser.username; _userId = authenticatedUser.userId; _userToken = authenticatedUser.userToken; _checkedLocalStorage = true; } catch (err) { logger.warn( 'Unable to read authentication details from localStorage. Player authentication will not be available.', err ); } }; /** * Helper to be called on login or error. * Removes all the UI and timeouts. */ const cleanUpAuthWindowAndTimeouts = (runtimeScene: RuntimeScene) => { removeAuthenticationContainer(runtimeScene); clearAuthenticationWindowTimeout(); // If there is a websocket communication (electron, cordova), close it. if (_websocket) { logger.info('Closing authentication websocket connection.'); _websocket.close(); _websocket = null; } // If a new window was opened (web), close it. if (_authenticationWindow) { _authenticationWindow.close(); _authenticationWindow = null; } // If cordova (native mobile app), hide external window. // TODO: calling hide does nothing on Android, the plugin should be updated to handle the action `hide`. if (typeof SafariViewController !== 'undefined') { try { SafariViewController.hide(); } catch (error) { logger.info( 'Could not hide login window. Waiting for user to do it.' ); } } }; const saveAuthKeyToStorage = ({ username, userId, userToken, }: { username: string | null; userId: string; userToken: string; }) => { if (!username) { logger.warn('The authenticated player does not have a username'); } _username = username; _userId = userId; _userToken = userToken; _justLoggedIn = true; const gameId = gdjs.projectData.properties.projectUuid; if (!gameId) { logger.error('Missing game id in project properties.'); return; } try { window.localStorage.setItem( getLocalStorageKey(gameId), JSON.stringify({ username: _username, userId: _userId, userToken: _userToken, }) ); } catch (err) { logger.warn( 'Unable to save the authentication details to localStorage. Player authentication will not be available.', err ); } }; export const login = ({ runtimeScene, userId, username, userToken, }: { runtimeScene: gdjs.RuntimeScene; userId: string; username: string | null; userToken: string; }) => { saveAuthKeyToStorage({ userId, username, userToken }); cleanUpAuthWindowAndTimeouts(runtimeScene); removeAuthenticationBanner(runtimeScene); const domElementContainer = runtimeScene .getGame() .getRenderer() .getDomElementContainer(); if (!domElementContainer) { handleAuthenticationError( runtimeScene, "The div element covering the game couldn't be found, the authentication banner cannot be displayed." ); return; } authComponents.displayLoggedInNotification( domElementContainer, _username || 'Anonymous' ); }; /** * Reads the event sent by the authentication window and * display the appropriate banner. */ const receiveAuthenticationMessage = function ({ runtimeScene, event, checkOrigin, onDone, }: { runtimeScene: gdjs.RuntimeScene; event: MessageEvent; checkOrigin: boolean; onDone?: (status: 'logged' | 'errored' | 'dismissed') => void; }) { // This happens when the user triggers the login from the game. // -> This will close the authentication window and log the user in. const onManualLoginSuccessFul = (event: MessageEvent) => { login({ runtimeScene, userId: event.data.body.userId, username: event.data.body.username, userToken: event.data.body.token, }); focusOnGame(runtimeScene); if (onDone) onDone('logged'); }; // This happens when the login happens automatically, because the parent window // (e.g: GDevelop games platform) is already authenticated. // -> This logs the user in, without closing the banner or showing the login notification. const onAutomaticLoginSuccessful = (event: MessageEvent) => { saveAuthKeyToStorage({ userId: event.data.body.userId, username: event.data.body.username, userToken: event.data.body.token, }); removeAutomaticGamesPlatformAuthenticationCallback(); refreshAuthenticationBannerIfAny(runtimeScene); if (onDone) onDone('logged'); }; const allowedOrigins = ['https://gd.games', 'http://localhost:4000']; // Check origin of message. if (checkOrigin && !allowedOrigins.includes(event.origin)) { // Automatic authentication message ignored: wrong origin. Return silently. return; } // Check that message is not malformed. if (!event.data.id) { throw new Error('Malformed message'); } // Handle message. switch (event.data.id) { case 'authenticationResult': { if (!(event.data.body && event.data.body.token)) { throw new Error('Malformed message.'); } logger.info('Received authentication result, logging in player.'); onManualLoginSuccessFul(event); break; } case 'alreadyAuthenticated': { if (!(event.data.body && event.data.body.token)) { throw new Error('Malformed message.'); } logger.info('Player is already authenticated, logging in player.'); // If we receive this message while the authentication dialog is open, // it can come from the parent window (e.g: GDevelop games platform) which is handling the authentication for the game. // In this case, we assume the log-in was successful and initiated by the player. if (_authenticationRootContainer) { onManualLoginSuccessFul(event); break; } // If the authentication dialog is not open, it means that the parent window (e.g: GDevelop games platform) has informed the game // that the player is already authenticated. We can log the player in automatically. onAutomaticLoginSuccessful(event); break; } } }; /** * Handle any error that can occur as part of the authentication process. */ const handleAuthenticationError = function ( runtimeScene: gdjs.RuntimeScene, message: string ) { logger.error(message); cleanUpAuthWindowAndTimeouts(runtimeScene); const domElementContainer = runtimeScene .getGame() .getRenderer() .getDomElementContainer(); if (!domElementContainer) { handleAuthenticationError( runtimeScene, "The div element covering the game couldn't be found, the authentication banner cannot be displayed." ); return; } authComponents.displayErrorNotification(domElementContainer); focusOnGame(runtimeScene); }; /** * If after 5min, no message has been received from the authentication window, * show a notification and remove the authentication container. */ const startAuthenticationWindowTimeout = ( runtimeScene: gdjs.RuntimeScene ) => { clearAuthenticationWindowTimeout(); const time = 15 * 60 * 1000; // 15 minutes, in case the user needs time to authenticate. _authenticationTimeoutId = setTimeout(() => { logger.info( 'Authentication window did not send message in time. Closing it.' ); cleanUpAuthWindowAndTimeouts(runtimeScene); focusOnGame(runtimeScene); }, time); }; /** * Clear all existing authentication timeouts. * Useful when: * - a new authentication starts * - the authentication succeeded * - the authentication window is closed */ const clearAuthenticationWindowTimeout = () => { if (_authenticationTimeoutId) clearTimeout(_authenticationTimeoutId); }; /** * Helper to create the authentication banner based on the authentication status. */ const createAuthenticationBanner = function ( runtimeScene: gdjs.RuntimeScene ): HTMLDivElement { const onDismissBanner = () => { removeAuthenticationBanner(runtimeScene); }; const onOpenAuthenticationWindow = () => { openAuthenticationWindow(runtimeScene); }; return _userToken ? authComponents.computeAuthenticatedBanner( onOpenAuthenticationWindow, onDismissBanner, _username ) : authComponents.computeNotAuthenticatedBanner( onOpenAuthenticationWindow, onDismissBanner ); }; /** * Action to display the banner to the user, depending on their authentication status. */ export const displayAuthenticationBanner = function ( runtimeScene: gdjs.RuntimeScene ) { if (runtimeScene.getGame().isInGameEdition()) { return; } if (_authenticationBanner) { // Banner already displayed, ensure it's visible. _authenticationBanner.style.opacity = '1'; return; } if (!_checkedLocalStorage) { readAuthenticatedUserFromLocalStorage(); } const domElementContainer = runtimeScene .getGame() .getRenderer() .getDomElementContainer(); if (!domElementContainer) { handleAuthenticationError( runtimeScene, "The div element covering the game couldn't be found, the authentication banner cannot be displayed." ); return; } _authenticationBanner = createAuthenticationBanner(runtimeScene); domElementContainer.appendChild(_authenticationBanner); }; /** * Helper to recompute the authentication banner. * This is useful if the user is already logged on GDevelop games platform * and we want to display the banner with the username. */ const refreshAuthenticationBannerIfAny = function ( runtimeScene: gdjs.RuntimeScene ) { if (!_authenticationBanner) return; const domElementContainer = runtimeScene .getGame() .getRenderer() .getDomElementContainer(); if (!domElementContainer) { handleAuthenticationError( runtimeScene, "The div element covering the game couldn't be found, the authentication banner cannot be displayed." ); return; } const oldAuthenticationBanner = _authenticationBanner; _authenticationBanner = createAuthenticationBanner(runtimeScene); domElementContainer.replaceChild( _authenticationBanner, oldAuthenticationBanner ); }; const setupWebsocketForAuthenticationWindow = ( runtimeScene: gdjs.RuntimeScene, gameId: string, onOpenAuthenticationWindow: (options: { connectionId: string; resolve: (AuthenticationWindowStatus) => void; }) => void ) => new Promise((resolve) => { let hasFinishedAlready = false; const wsPlayApi = runtimeScene .getGame() .isUsingGDevelopDevelopmentEnvironment() ? `wss://api-ws-dev.gdevelop.io/play?gameId=${gameId}&connectionType=login` : `wss://api-ws.gdevelop.io/play?gameId=${gameId}&connectionType=login`; _websocket = new WebSocket(wsPlayApi); _websocket.onopen = () => { logger.info('Opened authentication websocket connection.'); // When socket is open, ask for the connectionId, so that we can open the authentication window. if (_websocket) { _websocket.send(JSON.stringify({ action: 'getConnectionId' })); } }; _websocket.onerror = () => { logger.info('Error in authentication websocket connection.'); if (!hasFinishedAlready) { hasFinishedAlready = true; resolve('errored'); } handleAuthenticationError( runtimeScene, 'Error while connecting to the authentication server.' ); }; _websocket.onclose = () => { logger.info('Closing authentication websocket connection.'); if (!hasFinishedAlready) { hasFinishedAlready = true; resolve('dismissed'); } }; _websocket.onmessage = (event) => { if (event.data) { const messageContent = JSON.parse(event.data); switch (messageContent.type) { case 'authenticationResult': { const messageData = messageContent.data; login({ runtimeScene, userId: messageData.userId, username: messageData.username, userToken: messageData.token, }); focusOnGame(runtimeScene); hasFinishedAlready = true; resolve('logged'); break; } case 'connectionId': { const messageData = messageContent.data; const connectionId = messageData.connectionId; if (!connectionId) { logger.error('No WebSocket connectionId received'); hasFinishedAlready = true; resolve('errored'); return; } logger.info('WebSocket connectionId received.'); onOpenAuthenticationWindow({ connectionId, resolve }); break; } } } }; }); /** * Helper to handle authentication window on Electron. * We open a new window, and create a websocket to know when the user is logged in. */ const openAuthenticationWindowForElectron = ( runtimeScene: gdjs.RuntimeScene, gameId: string, authWindowOptions: AuthenticationWindowOptions ) => setupWebsocketForAuthenticationWindow( runtimeScene, gameId, ({ connectionId }) => { const targetUrl = getAuthWindowUrl({ runtimeGame: runtimeScene.getGame(), gameId, connectionId, authWindowOptions, }); const electron = runtimeScene.getGame().getRenderer().getElectron(); const openWindow = () => electron.shell.openExternal(targetUrl); openWindow(); // Add the link to the window in case a popup blocker is preventing the window from opening. if (_authenticationTextContainer) { authComponents.addAuthenticationUrlToTextsContainer( openWindow, _authenticationTextContainer ); } } ); /** * Helper to handle authentication window on Cordova on iOS and Android. * We open an external window, and listen to the websocket to know when the user is logged in. */ const openAuthenticationWindowForCordovaWithWebSocket = ( runtimeScene: gdjs.RuntimeScene, gameId: string, authWindowOptions: AuthenticationWindowOptions ) => setupWebsocketForAuthenticationWindow( runtimeScene, gameId, ({ connectionId, resolve }) => { const targetUrl = getAuthWindowUrl({ runtimeGame: runtimeScene.getGame(), gameId, connectionId, authWindowOptions, }); if (typeof SafariViewController === 'undefined') { logger.error( 'Cordova plugin SafariViewController is not installed.' ); resolve('errored'); return; } SafariViewController.isAvailable(function (available: boolean) { if (!available) { logger.error( 'Cordova plugin SafariViewController is installed but not available' ); resolve('errored'); return; } logger.info( 'Opening authentication window for Cordova with SafariViewController.' ); SafariViewController.show( { url: targetUrl, hidden: false, animated: true, transition: 'slide', enterReaderModeIfAvailable: false, barColor: '#000000', tintColor: '#ffffff', controlTintColor: '#ffffff', }, function (result: any) { // Other events are `opened` and `loaded`. if (result.event === 'closed') { resolve('dismissed'); } }, function (error: any) { logger.log( 'Error opening authentication window: ' + JSON.stringify(error) ); resolve('errored'); } ); }); } ); /** * Helper to handle authentication window on web. * We open a new window, and listen to messages posted back to the game window. */ const openAuthenticationWindowForWeb = ( runtimeScene: gdjs.RuntimeScene, gameId: string, authWindowOptions: AuthenticationWindowOptions ) => new Promise((resolve) => { // If we're on a browser, open a new window. const targetUrl = getAuthWindowUrl({ runtimeGame: runtimeScene.getGame(), gameId, authWindowOptions, }); // Listen to messages posted by the authentication window, so that we can // know when the user is authenticated. let isDoneAlready = false; _authenticationMessageCallback = (event: MessageEvent) => { receiveAuthenticationMessage({ runtimeScene, event, checkOrigin: true, onDone: (status) => { if (isDoneAlready) return; isDoneAlready = true; resolve(status); }, }); }; window.addEventListener( 'message', _authenticationMessageCallback, true ); const left = screen.width / 2 - 500 / 2; const top = screen.height / 2 - 600 / 2; const windowFeatures = `left=${left},top=${top},width=500,height=600`; const openWindow = () => { _authenticationWindow = window.open( targetUrl, 'authentication', windowFeatures ); }; openWindow(); // Add the link to the window in case a popup blocker is preventing the window from opening. if (_authenticationTextContainer) { authComponents.addAuthenticationUrlToTextsContainer( openWindow, _authenticationTextContainer ); } }); /** * Helper to handle authentication iframe on web. * We open an iframe, and listen to messages posted back to the game window. */ const openAuthenticationIframeForWeb = ( runtimeScene: gdjs.RuntimeScene, gameId: string, authWindowOptions: AuthenticationWindowOptions ) => new Promise((resolve) => { if ( !_authenticationIframeContainer || !_authenticationLoaderContainer || !_authenticationTextContainer ) { logger.error( "Can't open an authentication iframe - no iframe container, loader container or text container was opened for it." ); return; } const targetUrl = getAuthWindowUrl({ runtimeGame: runtimeScene.getGame(), gameId, authWindowOptions, }); // Listen to messages posted by the authentication window, so that we can // know when the user is authenticated. _authenticationMessageCallback = (event: MessageEvent) => { receiveAuthenticationMessage({ runtimeScene, event, checkOrigin: true, onDone: resolve, }); }; window.addEventListener( 'message', _authenticationMessageCallback, true ); authComponents.displayIframeInsideAuthenticationContainer( _authenticationIframeContainer, _authenticationLoaderContainer, _authenticationTextContainer, targetUrl ); }); export const openAuthenticationWindowForGamesPlatform = ( runtimeScene: gdjs.RuntimeScene, gameId: string, authWindowOptions: AuthenticationWindowOptions ) => new Promise((resolve) => { // First, clear the automatic authentication timeout. // It can still exist if the user triggers a log-in manually, while the automatic authentication is still waiting. removeAutomaticGamesPlatformAuthenticationCallback(); // Listen to messages posted by the authentication window, so that we can // know when the user is authenticated. _authenticationMessageCallback = (event: MessageEvent) => { receiveAuthenticationMessage({ runtimeScene, event, checkOrigin: true, onDone: resolve, }); }; window.addEventListener( 'message', _authenticationMessageCallback, true ); // Login dialog will be handled by the platform. window.parent.postMessage( { id: 'openGameAuthenticationDialog', gameId, disableGuestLogin: authWindowOptions.disableGuestLogin, }, '*' // We could restrict to GDevelop games platform but it's not necessary as the message is not sensitive, and it allows easy debugging. ); }); /** * Action to display the authentication window to the user. */ export const openAuthenticationWindow = ( runtimeScene: gdjs.RuntimeScene, authWindowOptions: AuthenticationWindowOptions = { disableGuestLogin: false, } ): gdjs.PromiseTask<{ status: 'logged' | 'errored' | 'dismissed' }> => new gdjs.PromiseTask( new Promise((resolve) => { if (runtimeScene.getGame().isInGameEdition()) { resolve({ status: 'dismissed' }); } // Create the authentication container for the player to wait. const domElementContainer = runtimeScene .getGame() .getRenderer() .getDomElementContainer(); if (!domElementContainer) { handleAuthenticationError( runtimeScene, "The div element covering the game couldn't be found, the authentication window cannot be displayed." ); resolve({ status: 'errored' }); return; } const _gameId = gdjs.projectData.properties.projectUuid; if (!_gameId) { handleAuthenticationError( runtimeScene, 'The game ID is missing, the authentication window cannot be opened.' ); resolve({ status: 'errored' }); return; } let isDismissedAlready = false; const onAuthenticationContainerDismissed = () => { cleanUpAuthWindowAndTimeouts(runtimeScene); displayAuthenticationBanner(runtimeScene); isDismissedAlready = true; resolve({ status: 'dismissed' }); }; // If the banner is displayed, hide it, so that it can be shown again if the user closes the window. if (_authenticationBanner) _authenticationBanner.style.opacity = '0'; const playerAuthPlatform = getPlayerAuthPlatform(runtimeScene); const { rootContainer, loaderContainer, iframeContainer } = authComponents.computeAuthenticationContainer( onAuthenticationContainerDismissed ); _authenticationRootContainer = rootContainer; _authenticationLoaderContainer = loaderContainer; _authenticationIframeContainer = iframeContainer; // Display the authentication window right away, to show a loader // while the call for game registration is happening. domElementContainer.appendChild(_authenticationRootContainer); // If the game is registered, open the authentication window. // Otherwise, open the window indicating that the game is not registered. (async () => { const isGameRegistered = await checkIfGameIsRegistered( runtimeScene.getGame(), _gameId ); if (_authenticationLoaderContainer) { const electron = runtimeScene .getGame() .getRenderer() .getElectron(); const wikiOpenAction = electron ? () => electron.shell.openExternal( 'https://wiki.gdevelop.io/gdevelop5/publishing/web' ) : null; // Only show a link if we're on electron. _authenticationTextContainer = authComponents.addAuthenticationTextsToLoadingContainer( _authenticationLoaderContainer, playerAuthPlatform, isGameRegistered, wikiOpenAction ); } if (!isGameRegistered) return; startAuthenticationWindowTimeout(runtimeScene); // Based on which platform the game is running, we open the authentication window // with a different window, with or without a websocket. let status: AuthenticationWindowStatus; switch (playerAuthPlatform) { case 'electron': // This can be a: // - Preview in GDevelop desktop app. // - Desktop game running on Electron. status = await openAuthenticationWindowForElectron( runtimeScene, _gameId, authWindowOptions ); break; case 'cordova-websocket': // The game is an iOS or Android app. status = await openAuthenticationWindowForCordovaWithWebSocket( runtimeScene, _gameId, authWindowOptions ); break; case 'web-iframe': // This can be a: // - Preview in GDevelop mobile app. status = await openAuthenticationIframeForWeb( runtimeScene, _gameId, authWindowOptions ); break; case 'games-platform': // This game is running on gd.games. // The authentication is handled by the platform. status = await openAuthenticationWindowForGamesPlatform( runtimeScene, _gameId, authWindowOptions ); break; case 'web': default: // This can be a: // - Preview in GDevelop web-app // - Web game (any website/server except gd.games) accessed via a desktop browser... // - Or a web game accessed via a mobile browser (Android/iOS). status = await openAuthenticationWindowForWeb( runtimeScene, _gameId, authWindowOptions ); break; } if (isDismissedAlready) return; if (status === 'dismissed') { onAuthenticationContainerDismissed(); } resolve({ status }); })(); }) ); /** * Condition to check if the window is open, so that the game can be paused in the background. */ export const isAuthenticationWindowOpen = function (): boolean { return !!_authenticationRootContainer; }; /** * Remove the container displaying the authentication window and the callback. */ export const removeAuthenticationContainer = function ( runtimeScene: gdjs.RuntimeScene ) { removeAuthenticationCallbacks(); const domElementContainer = runtimeScene .getGame() .getRenderer() .getDomElementContainer(); if (!domElementContainer) { logger.info( "The div element covering the game couldn't be found, the authentication must be already closed." ); return; } // Remove the authentication root container. if (_authenticationRootContainer) { domElementContainer.removeChild(_authenticationRootContainer); } _authenticationRootContainer = null; _authenticationLoaderContainer = null; _authenticationIframeContainer = null; _authenticationTextContainer = null; }; /* * Remove the authentication callbacks from web or cordova. */ const removeAuthenticationCallbacks = function () { // Remove the authentication callbacks. if (_authenticationMessageCallback) { window.removeEventListener( 'message', _authenticationMessageCallback, true ); _authenticationMessageCallback = null; } }; /* * Remove the automatic authentication callback when running on web. */ const removeAutomaticGamesPlatformAuthenticationCallback = function () { if (_automaticGamesPlatformAuthenticationCallback) { window.removeEventListener( 'message', _automaticGamesPlatformAuthenticationCallback, true ); _automaticGamesPlatformAuthenticationCallback = null; } if (_automaticGamesPlatformAuthenticationTimeoutId) { clearTimeout(_automaticGamesPlatformAuthenticationTimeoutId); _automaticGamesPlatformAuthenticationTimeoutId = null; } }; /** * Remove the banner displaying the authentication status. */ export const removeAuthenticationBanner = function ( runtimeScene: gdjs.RuntimeScene ) { if (!_authenticationBanner) { logger.info( "The authentication banner couldn't be found, the authentication banner must be already closed." ); return; } const domElementContainer = runtimeScene .getGame() .getRenderer() .getDomElementContainer(); if (!domElementContainer) { logger.info( "The div element covering the game couldn't be found, the authentication must be already closed." ); return; } domElementContainer.removeChild(_authenticationBanner); _authenticationBanner = null; }; /** * Focus on game canvas to allow user to interact with it. */ const focusOnGame = function (runtimeScene: gdjs.RuntimeScene) { const gameCanvas = runtimeScene.getGame().getRenderer().getCanvas(); if (gameCanvas) gameCanvas.focus(); }; } }