Index: binaries/data/mods/_test.gui/gui/regainFocus/pushWithPopOnInit.js =================================================================== --- binaries/data/mods/_test.gui/gui/regainFocus/pushWithPopOnInit.js +++ binaries/data/mods/_test.gui/gui/regainFocus/pushWithPopOnInit.js @@ -1 +1 @@ -Engine.PushGuiPage("regainFocus/page_emptyPage.xml", {}, () => Engine.PopGuiPage()); +Engine.PushGuiPage("regainFocus/page_emptyPage.xml").then(Engine.PopGuiPage); Index: binaries/data/mods/_test.scriptinterface/promises/simple.js =================================================================== --- /dev/null +++ binaries/data/mods/_test.scriptinterface/promises/simple.js @@ -0,0 +1,30 @@ +let test = 0; + +function incrementTest() +{ + test += 1; +} + +async function waitAndIncrement(promise) +{ + await promise; + incrementTest(); +} + +{ + let resolve; + const promise = new Promise(res => { + incrementTest(); + resolve = res; + }); + waitAndIncrement(promise); + TS_ASSERT_EQUALS(test, 1); + resolve(); + // At this point, waitAndIncrement is still not run, but is now free to run. + TS_ASSERT_EQUALS(test, 1); +} + +function endTest() +{ + TS_ASSERT_EQUALS(test, 2); +} Index: binaries/data/mods/mod/gui/common/functions_msgbox.js =================================================================== --- binaries/data/mods/mod/gui/common/functions_msgbox.js +++ binaries/data/mods/mod/gui/common/functions_msgbox.js @@ -1,6 +1,7 @@ -function messageBox(mbWidth, mbHeight, mbMessage, mbTitle, mbButtonCaptions, mbBtnCode, mbCallbackArgs) +async function messageBox(mbWidth, mbHeight, mbMessage, mbTitle, mbButtonCaptions, mbBtnCode, + mbCallbackArgs) { - Engine.PushGuiPage( + const btnCode = await Engine.PushGuiPage( "page_msgbox.xml", { "width": mbWidth, @@ -8,16 +9,16 @@ "message": mbMessage, "title": mbTitle, "buttonCaptions": mbButtonCaptions - }, - btnCode => { - if (mbBtnCode !== undefined && mbBtnCode[btnCode]) - mbBtnCode[btnCode](mbCallbackArgs ? mbCallbackArgs[btnCode] : undefined); }); + + if (mbBtnCode !== undefined && mbBtnCode[btnCode]) + mbBtnCode[btnCode](mbCallbackArgs ? mbCallbackArgs[btnCode] : undefined); } -function timedConfirmation(width, height, message, timeParameter, timeout, title, buttonCaptions, btnCode, callbackArgs) +async function timedConfirmation(width, height, message, timeParameter, timeout, title, buttonCaptions, + btnCode, callbackArgs) { - Engine.PushGuiPage( + const button = await Engine.PushGuiPage( "page_timedconfirmation.xml", { "width": width, @@ -27,22 +28,10 @@ "timeout": timeout, "title": title, "buttonCaptions": buttonCaptions - }, - button => { - if (btnCode !== undefined && btnCode[button]) - btnCode[button](callbackArgs ? callbackArgs[button] : undefined); }); -} -function colorMixer(color, callback) -{ - Engine.PushGuiPage( - "page_colormixer.xml", - color, - result => { - callback(result); - } - ); + if (btnCode !== undefined && btnCode[button]) + btnCode[button](callbackArgs ? callbackArgs[button] : undefined); } function openURL(url) Index: binaries/data/mods/mod/gui/common/terms.js =================================================================== --- binaries/data/mods/mod/gui/common/terms.js +++ binaries/data/mods/mod/gui/common/terms.js @@ -5,9 +5,9 @@ g_Terms = terms; } -function openTerms(page) +async function openTerms(page) { - Engine.PushGuiPage( + const data = await Engine.PushGuiPage( "page_termsdialog.xml", { "file": g_Terms[page].file, @@ -16,19 +16,17 @@ "urlButtons": g_Terms[page].urlButtons || [], "termsURL": g_Terms[page].termsURL || undefined, "page": page - }, - data => { - g_Terms[data.page].accepted = data.accepted; - - Engine.ConfigDB_CreateAndSaveValue( - "user", - g_Terms[data.page].config, - data.accepted ? getTermsHash(data.page) : "0"); - - if (g_Terms[data.page].callback) - g_Terms[data.page].callback(data); - } - ); + }); + + g_Terms[data.page].accepted = data.accepted; + + Engine.ConfigDB_CreateAndSaveValue( + "user", + g_Terms[data.page].config, + data.accepted ? getTermsHash(data.page) : "0"); + + if (g_Terms[data.page].callback) + g_Terms[data.page].callback(data); } function checkTerms() Index: binaries/data/mods/mod/gui/modmod/modmodio.js =================================================================== --- binaries/data/mods/mod/gui/modmod/modmodio.js +++ binaries/data/mods/mod/gui/modmod/modmodio.js @@ -23,8 +23,11 @@ openTerms("Disclaimer"); } -function openModIo(data) +async function openModIo(data) { - if (data.accepted) - Engine.PushGuiPage("page_modio.xml", {}, initMods); + if (!data.accepted) + return; + + await Engine.PushGuiPage("page_modio.xml"); + initMods(); } Index: binaries/data/mods/public/gui/campaigns/default_menu/CampaignMenu.js =================================================================== --- binaries/data/mods/public/gui/campaigns/default_menu/CampaignMenu.js +++ binaries/data/mods/public/gui/campaigns/default_menu/CampaignMenu.js @@ -19,16 +19,21 @@ this.levelSelection.onMouseLeftDoubleClickItem = () => this.startScenario(); Engine.GetGUIObjectByName('startButton').onPress = () => this.startScenario(); Engine.GetGUIObjectByName('backToMain').onPress = () => this.goBackToMainMenu(); - Engine.GetGUIObjectByName('savedGamesButton').onPress = Engine.PushGuiPage.bind(Engine, - 'page_loadgame.xml', { "campaignRun": this.run.filename }, this.loadSavegame.bind(this)); + Engine.GetGUIObjectByName('savedGamesButton').onPress = this.loadSavegame.bind(this); this.mapCache = new MapCache(); this._ready = true; } - loadSavegame(gameId) + async loadSavegame() { + const gameId = await Engine.PushGuiPage( + 'page_loadgame.xml', + { + "campaignRun": this.run.filename + }); + if (!gameId) return; Index: binaries/data/mods/public/gui/common/functions_utility.js =================================================================== --- binaries/data/mods/public/gui/common/functions_utility.js +++ binaries/data/mods/public/gui/common/functions_utility.js @@ -288,19 +288,15 @@ * property that page is opened with the @a args property of that object. * That continues untill there is no @a nextPage property in the completion * value. If there is no @a nextPage in the completion value the - * @a continuation is called with the completion value. + * @a completionValue is returned. * @param {String} page - The page first opened. * @param args - passed to the first page opened. - * @param continuation {function | undefined} - Completion callback, called when - * there is no @a nextPage property in the completion value. */ -function pageLoop(page, args, continuation) +async function pageLoop(page, args) { - (function recursiveFunction(completionValue) - { - if (completionValue?.nextPage != null) - Engine.PushGuiPage(completionValue.nextPage, completionValue.args, recursiveFunction); - else - continuation?.(completionValue); - })({ "nextPage": page, "args": args }); + let completionValue = { "nextPage": page, "args": args }; + while (completionValue?.nextPage != null) + completionValue = await Engine.PushGuiPage(completionValue.nextPage, completionValue.args); + + return completionValue; } Index: binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/CivInfoButton.js =================================================================== --- binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/CivInfoButton.js +++ binaries/data/mods/public/gui/gamesetup/Pages/GameSetupPage/Panels/Buttons/CivInfoButton.js @@ -23,9 +23,9 @@ this.openPage(this.civInfo.page); } - openPage(page) + async openPage(page) { - pageLoop(page, this.civInfo.args, data => this.civInfo = data); + this.civInfo = await pageLoop(page, this.civInfo.args); } } Index: binaries/data/mods/public/gui/locale/locale.js =================================================================== --- binaries/data/mods/public/gui/locale/locale.js +++ binaries/data/mods/public/gui/locale/locale.js @@ -52,14 +52,11 @@ localeText.caption = locale; } -function openAdvancedMenu() +async function openAdvancedMenu() { let localeText = Engine.GetGUIObjectByName("localeText"); - Engine.PushGuiPage("page_locale_advanced.xml", { "locale": localeText.caption }, applyFromAdvancedMenu); -} + const locale = await Engine.PushGuiPage("page_locale_advanced.xml", { "locale": localeText.caption }); -function applyFromAdvancedMenu(locale) -{ if (!locale) return; @@ -72,6 +69,5 @@ if (index != -1) languageList.selected = index; - var localeText = Engine.GetGUIObjectByName("localeText"); localeText.caption = locale; } Index: binaries/data/mods/public/gui/options/options.js =================================================================== --- binaries/data/mods/public/gui/options/options.js +++ binaries/data/mods/public/gui/options/options.js @@ -80,17 +80,14 @@ control.caption = value; }, "initGUI": (option, control) => { - control.children[2].onPress = () => { - colorMixer( - control.caption, - (color) => { - if (color != control.caption) - { - control.caption = color; - control.onTextEdit(); - } - } - ); + control.children[2].onPress = async() => { + const color = await Engine.PushGuiPage("page_colormixer.xml", control.caption); + + if (color != control.caption) + { + control.caption = color; + control.onTextEdit(); + } }; }, "guiToValue": control => control.caption, Index: binaries/data/mods/public/gui/pregame/MainMenuItems.js =================================================================== --- binaries/data/mods/public/gui/pregame/MainMenuItems.js +++ binaries/data/mods/public/gui/pregame/MainMenuItems.js @@ -90,31 +90,32 @@ { "caption": translate("Load Game"), "tooltip": translate("Load a saved game."), - "onPress": Engine.PushGuiPage.bind(Engine, "page_loadgame.xml", {}, (gameId) => - { - if (!gameId) - return; + "onPress": async() => { + const gameId = await Engine.PushGuiPage("page_loadgame.xml"); - const metadata = Engine.StartSavedGame(gameId); - if (!metadata) - { - error("Could not load saved game: " + gameId); - return; - } + if (!gameId) + return; - Engine.SwitchGuiPage("page_loading.xml", { - "attribs": metadata.initAttributes, - "playerAssignments": { - "local": { - "name": metadata.initAttributes.settings. - PlayerData[metadata.playerID]?.Name ?? - singleplayerName(), - "player": metadata.playerID - } - }, - "savedGUIData": metadata.gui - }); - }) + const metadata = Engine.StartSavedGame(gameId); + if (!metadata) + { + error("Could not load saved game: " + gameId); + return; + } + + Engine.SwitchGuiPage("page_loading.xml", { + "attribs": metadata.initAttributes, + "playerAssignments": { + "local": { + "name": metadata.initAttributes.settings. + PlayerData[metadata.playerID]?.Name ?? + singleplayerName(), + "player": metadata.playerID + } + }, + "savedGUIData": metadata.gui + }); + } }, { "caption": translate("Continue Campaign"), @@ -221,11 +222,8 @@ { "caption": translate("Options"), "tooltip": translate("Adjust game settings."), - "onPress": () => { - Engine.PushGuiPage( - "page_options.xml", - {}, - fireConfigChangeHandlers); + "onPress": async() => { + fireConfigChangeHandlers(await Engine.PushGuiPage("page_options.xml")); } }, { Index: binaries/data/mods/public/gui/session/MenuButtons.js =================================================================== --- binaries/data/mods/public/gui/session/MenuButtons.js +++ binaries/data/mods/public/gui/session/MenuButtons.js @@ -16,11 +16,12 @@ this.pauseControl = pauseControl; } - onPress() + async onPress() { closeOpenDialogs(); this.pauseControl.implicitPause(); - Engine.PushGuiPage("page_manual.xml", {}, resumeGame); + await Engine.PushGuiPage("page_manual.xml"); + resumeGame(); } }; @@ -54,18 +55,18 @@ this.pauseControl = pauseControl; } - onPress() + async onPress() { closeOpenDialogs(); this.pauseControl.implicitPause(); - Engine.PushGuiPage( + await Engine.PushGuiPage( "page_loadgame.xml", { "savedGameData": getSavedGameData(), "campaignRun": g_CampaignSession ? g_CampaignSession.run.filename : null - }, - resumeGame); + }); + resumeGame(); } }; @@ -92,7 +93,7 @@ }); } - onPress() + async onPress() { if (Engine.IsAtlasRunning()) return; @@ -102,7 +103,7 @@ // Allows players to see their own summary. // If they have shared ally vision researched, they are able to see the summary of there allies too. let simState = Engine.GuiInterfaceCall("GetExtendedSimulationState"); - Engine.PushGuiPage( + const data = await Engine.PushGuiPage( "page_summary.xml", { "sim": { @@ -117,11 +118,10 @@ "isInGame": true, "summarySelection": this.summarySelection }, - }, - data => { - this.summarySelection = data.summarySelection; - this.pauseControl.implicitResume(); }); + + this.summarySelection = data.summarySelection; + this.pauseControl.implicitResume(); } }; @@ -162,18 +162,13 @@ this.pauseControl = pauseControl; } - onPress() + async onPress() { closeOpenDialogs(); this.pauseControl.implicitPause(); - Engine.PushGuiPage( - "page_options.xml", - {}, - changes => { - fireConfigChangeHandlers(changes); - resumeGame(); - }); + fireConfigChangeHandlers(await Engine.PushGuiPage("page_options.xml")); + resumeGame(); } }; @@ -186,15 +181,13 @@ this.pauseControl = pauseControl; } - onPress() + async onPress() { closeOpenDialogs(); this.pauseControl.implicitPause(); - Engine.PushGuiPage( - "hotkeys/page_hotkeys.xml", - {}, - () => { resumeGame(); }); + await Engine.PushGuiPage("hotkeys/page_hotkeys.xml"); + resumeGame(); } }; Index: binaries/data/mods/public/gui/session/SessionMessageBox.js =================================================================== --- binaries/data/mods/public/gui/session/SessionMessageBox.js +++ binaries/data/mods/public/gui/session/SessionMessageBox.js @@ -4,11 +4,12 @@ */ class SessionMessageBox { - display() + async display() { - this.onPageOpening(); + closeOpenDialogs(); + g_PauseControl.implicitPause(); - Engine.PushGuiPage( + const buttonId = await Engine.PushGuiPage( "page_msgbox.xml", { "width": this.Width, @@ -16,18 +17,8 @@ "title": this.Title, "message": this.Caption, "buttonCaptions": this.Buttons ? this.Buttons.map(button => button.caption) : undefined, - }, - this.onPageClosed.bind(this)); - } + }); - onPageOpening() - { - closeOpenDialogs(); - g_PauseControl.implicitPause(); - } - - onPageClosed(buttonId) - { if (this.Buttons && this.Buttons[buttonId].onPress) this.Buttons[buttonId].onPress.call(this); Index: binaries/data/mods/public/gui/session/selection_panels.js =================================================================== --- binaries/data/mods/public/gui/session/selection_panels.js +++ binaries/data/mods/public/gui/session/selection_panels.js @@ -1262,19 +1262,19 @@ * * @param {string} [civCode] - The template name of the entity that researches the selected technology. */ -function showTemplateDetails(templateName, civCode) +async function showTemplateDetails(templateName, civCode) { if (inputState != INPUT_NORMAL) return; g_PauseControl.implicitPause(); - Engine.PushGuiPage( + await Engine.PushGuiPage( "page_viewer.xml", { "templateName": templateName, "civ": civCode - }, - resumeGame); + }); + resumeGame(); } /** Index: binaries/data/mods/public/gui/session/top_panel/CivIcon.js =================================================================== --- binaries/data/mods/public/gui/session/top_panel/CivIcon.js +++ binaries/data/mods/public/gui/session/top_panel/CivIcon.js @@ -29,12 +29,12 @@ this.openPage(this.dialogSelection.page); } - openPage(page) + async openPage(page) { closeOpenDialogs(); g_PauseControl.implicitPause(); - pageLoop( + this.dialogSelection = await pageLoop( page, { // If an Observer triggers `openPage()` via hotkey, g_ViewedPlayer could be -1 or 0 @@ -42,12 +42,8 @@ "civ": this.dialogSelection.args.civ ?? g_Players[Math.max(g_ViewedPlayer, 1)].civ // TODO add info about researched techs and unlocked entities - }, - data => - { - this.dialogSelection = data; - resumeGame(); }); + resumeGame(); } rebuild() Index: source/gui/GUIManager.h =================================================================== --- source/gui/GUIManager.h +++ source/gui/GUIManager.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2022 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -68,9 +68,9 @@ * Load a new GUI page and make it active. All current pages will be retained, * and will still be drawn and receive tick events, but will not receive * user inputs. - * If given, the callbackHandler function will be executed once this page is closed. + * The returned promise will be fulfilled once the pushed page is closed. */ - void PushPage(const CStrW& pageName, Script::StructuredClone initData, JS::HandleValue callbackFunc); + JS::Value PushPage(const std::wstring& pageName, Script::StructuredClone initData); /** * Unload the currently active GUI page, and make the previous page active. @@ -146,16 +146,17 @@ void LoadPage(std::shared_ptr scriptContext); /** - * Sets the callback handler when a new page is opened that will be performed when the page is closed. + * A new promise gets set. A reference to that promice is returned. The promise will settle when + * the page is closed. */ - void SetCallbackFunction(ScriptInterface& scriptInterface, JS::HandleValue callbackFunc); + JS::Value ReplacePromise(ScriptInterface& scriptInterface); /** * Execute the stored callback function with the given arguments. */ - void PerformCallbackFunction(Script::StructuredClone args); + void ResolvePromise(Script::StructuredClone args); - CStrW m_Name; + std::wstring m_Name; std::unordered_set inputs; // for hotloading Script::StructuredClone initData; // data to be passed to the init() function std::shared_ptr gui; // the actual GUI page @@ -164,7 +165,7 @@ * Function executed by this parent GUI page when the child GUI page it pushed is popped. * Notice that storing it in the SGUIPage instead of CGUI means that it will survive the hotloading CGUI reset. */ - std::shared_ptr callbackFunction; + std::shared_ptr callbackFunction; }; std::shared_ptr top() const; Index: source/gui/GUIManager.cpp =================================================================== --- source/gui/GUIManager.cpp +++ source/gui/GUIManager.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2023 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -29,6 +29,7 @@ #include "ps/XML/Xeromyces.h" #include "scriptinterface/FunctionWrapper.h" #include "scriptinterface/Object.h" +#include "scriptinterface/Promises.h" #include "scriptinterface/ScriptContext.h" #include "scriptinterface/ScriptInterface.h" #include "scriptinterface/StructuredClone.h" @@ -85,6 +86,9 @@ CGUIManager::~CGUIManager() { UnregisterFileReloadFunc(ReloadChangedFileCB, this); + + while (!m_PageStack.empty()) + m_PageStack.pop_back(); } size_t CGUIManager::GetPageCount() const @@ -108,27 +112,33 @@ { // Make sure we unfocus anything on the current page. m_PageStack.back().gui->SendFocusMessage(GUIM_LOST_FOCUS); - m_PageStack.clear(); + while (!m_PageStack.empty()) + m_PageStack.pop_back(); } - PushPage(pageName, initDataClone, JS::UndefinedHandleValue); + PushPage(pageName, initDataClone); } -void CGUIManager::PushPage(const CStrW& pageName, Script::StructuredClone initData, JS::HandleValue callbackFunction) +JS::Value CGUIManager::PushPage(const std::wstring& pageName, Script::StructuredClone initData) { + JSContext* generalContext{m_ScriptInterface->GetGeneralJSContext()}; // Store the callback handler in the current GUI page before opening the new one - if (!m_PageStack.empty() && !callbackFunction.isUndefined()) - { - m_PageStack.back().SetCallbackFunction(*m_ScriptInterface, callbackFunction); + JS::RootedValue promise{generalContext, [&] + { + if (m_PageStack.empty()) + return JS::UndefinedValue(); - // Make sure we unfocus anything on the current page. - m_PageStack.back().gui->SendFocusMessage(GUIM_LOST_FOCUS); - } + // Make sure we unfocus anything on the current page. + m_PageStack.back().gui->SendFocusMessage(GUIM_LOST_FOCUS); + return m_PageStack.back().ReplacePromise(*m_ScriptInterface); + }()}; // Push the page prior to loading its contents, because that may push // another GUI page on init which should be pushed on top of this new page. m_PageStack.emplace_back(pageName, initData); m_PageStack.back().LoadPage(m_ScriptContext); + + return promise; } void CGUIManager::PopPage(Script::StructuredClone args) @@ -143,7 +153,7 @@ m_PageStack.back().gui->SendFocusMessage(GUIM_LOST_FOCUS); m_PageStack.pop_back(); - m_PageStack.back().PerformCallbackFunction(args); + m_PageStack.back().ResolvePromise(args); // We return to a page where some object might have been focused. m_PageStack.back().gui->SendFocusMessage(GUIM_GOT_FOCUS); @@ -244,26 +254,17 @@ LOGERROR("GUI page '%s': Failed to call init() function", utf8_from_wstring(m_Name)); } -void CGUIManager::SGUIPage::SetCallbackFunction(ScriptInterface& scriptInterface, JS::HandleValue callbackFunc) +JS::Value CGUIManager::SGUIPage::ReplacePromise(ScriptInterface& scriptInterface) { - if (!callbackFunc.isObject()) - { - LOGERROR("Given callback handler is not an object!"); - return; - } + JSContext* generalContext{scriptInterface.GetGeneralJSContext()}; + callbackFunction = + std::make_shared(generalContext, + JS::NewPromiseObject(generalContext, nullptr)); - ScriptRequest rq(scriptInterface); - - if (!JS_ObjectIsFunction(&callbackFunc.toObject())) - { - LOGERROR("Given callback handler is not a function!"); - return; - } - - callbackFunction = std::make_shared(scriptInterface.GetGeneralJSContext(), callbackFunc); + return JS::ObjectValue(**callbackFunction); } -void CGUIManager::SGUIPage::PerformCallbackFunction(Script::StructuredClone args) +void CGUIManager::SGUIPage::ResolvePromise(Script::StructuredClone args) { if (!callbackFunction) return; @@ -273,7 +274,7 @@ JS::RootedObject globalObj(rq.cx, rq.glob); - JS::RootedValue funcVal(rq.cx, *callbackFunction); + JS::RootedObject funcVal{rq.cx, *callbackFunction}; // Delete the callback function, so that it is not called again callbackFunction.reset(); @@ -282,13 +283,8 @@ if (args) Script::ReadStructuredClone(rq, args, &argVal); - JS::RootedValueVector paramData(rq.cx); - ignore_result(paramData.append(argVal)); - - JS::RootedValue result(rq.cx); - - if(!JS_CallFunctionValue(rq.cx, globalObj, funcVal, paramData, &result)) - ScriptException::CatchPending(rq); + // This only resolves the promise, it doesn't call the continuation. + JS::ResolvePromise(rq.cx, funcVal, argVal); } Status CGUIManager::ReloadChangedFile(const VfsPath& path) @@ -387,6 +383,8 @@ for (const SGUIPage& p : pageStack) p.gui->TickObjects(); + + m_ScriptContext->RunJobs(); } void CGUIManager::Draw(CCanvas2D& canvas) const Index: source/gui/Scripting/JSInterface_GUIManager.cpp =================================================================== --- source/gui/Scripting/JSInterface_GUIManager.cpp +++ source/gui/Scripting/JSInterface_GUIManager.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2021 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -32,9 +32,10 @@ { // Note that the initData argument may only contain clonable data. // Functions aren't supported for example! -void PushGuiPage(const ScriptRequest& rq, const std::wstring& name, JS::HandleValue initData, JS::HandleValue callbackFunction) +// It returns a promise. +JS::Value PushGuiPage(const ScriptRequest& rq, const std::wstring& name, JS::HandleValue initData) { - g_GUI->PushPage(name, Script::WriteStructuredClone(rq, initData), callbackFunction); + return g_GUI->PushPage(name, Script::WriteStructuredClone(rq, initData)); } void SwitchGuiPage(const ScriptInterface& scriptInterface, const std::wstring& name, JS::HandleValue initData) Index: source/gui/tests/test_GuiManager.h =================================================================== --- source/gui/tests/test_GuiManager.h +++ source/gui/tests/test_GuiManager.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2023 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -26,6 +26,7 @@ #include "ps/GameSetup/GameSetup.h" #include "ps/Hotkey.h" #include "ps/XML/Xeromyces.h" +#include "scriptinterface/ScriptContext.h" #include "scriptinterface/ScriptRequest.h" #include "scriptinterface/ScriptInterface.h" #include "scriptinterface/StructuredClone.h" @@ -69,7 +70,7 @@ Script::CreateObject(rq, &val); Script::StructuredClone data = Script::WriteStructuredClone(rq, JS::NullHandleValue); - g_GUI->PushPage(L"event/page_event.xml", data, JS::UndefinedHandleValue); + g_GUI->PushPage(L"event/page_event.xml", data); const ScriptInterface& pageScriptInterface = *(g_GUI->GetActiveGUI()->GetScriptInterface()); ScriptRequest prq(pageScriptInterface); @@ -133,7 +134,7 @@ Script::CreateObject(rq, &val); Script::StructuredClone data = Script::WriteStructuredClone(rq, JS::NullHandleValue); - g_GUI->PushPage(L"hotkey/page_hotkey.xml", data, JS::UndefinedHandleValue); + g_GUI->PushPage(L"hotkey/page_hotkey.xml", data); // Press 'a'. SDL_Event_ hotkeyNotification; @@ -207,20 +208,29 @@ JS::RootedValue val(rq.cx); Script::CreateObject(rq, &val); + TS_ASSERT_EQUALS(g_GUI->GetPageCount(), 0); Script::StructuredClone data = Script::WriteStructuredClone(rq, JS::NullHandleValue); - g_GUI->PushPage(L"regainFocus/page_emptyPage.xml", data, JS::UndefinedHandleValue); + g_GUI->PushPage(L"regainFocus/page_emptyPage.xml", data); const ScriptInterface& pageScriptInterface = *(g_GUI->GetActiveGUI()->GetScriptInterface()); ScriptRequest prq(pageScriptInterface); JS::RootedValue global(prq.cx, prq.globalValue()); - g_GUI->PushPage(L"regainFocus/page_emptyPage.xml", data, JS::UndefinedHandleValue); + TS_ASSERT_EQUALS(g_GUI->GetPageCount(), 1); + g_GUI->PushPage(L"regainFocus/page_emptyPage.xml", data); + TS_ASSERT_EQUALS(g_GUI->GetPageCount(), 2); g_GUI->PopPage(data); + TS_ASSERT_EQUALS(g_GUI->GetPageCount(), 1); // This page instantly pushes an empty page with a callback that pops another page again. - g_GUI->PushPage(L"regainFocus/page_pushWithPopOnInit.xml", data, JS::UndefinedHandleValue); + g_GUI->PushPage(L"regainFocus/page_pushWithPopOnInit.xml", data); + TS_ASSERT_EQUALS(g_GUI->GetPageCount(), 3); - // Pop the empty page and trigger the callback (effectively pops twice). + // Pop the empty page. g_GUI->PopPage(data); + TS_ASSERT_EQUALS(g_GUI->GetPageCount(), 2); + // Execute the continuation. + scriptInterface.GetContext().RunJobs(); + TS_ASSERT_EQUALS(g_GUI->GetPageCount(), 1); } }; Index: source/network/NetServer.cpp =================================================================== --- source/network/NetServer.cpp +++ source/network/NetServer.cpp @@ -380,6 +380,8 @@ // Update profiler stats m_Stats->LatchHostState(m_Host); + + netServerContext->RunJobs(); } // Clear roots before deleting their context Index: source/ps/Replay.cpp =================================================================== --- source/ps/Replay.cpp +++ source/ps/Replay.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2022 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify Index: source/scriptinterface/Promises.h =================================================================== --- /dev/null +++ source/scriptinterface/Promises.h @@ -0,0 +1,65 @@ +/* Copyright (C) 2024 Wildfire Games. + * This file is part of 0 A.D. + * + * 0 A.D. is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * 0 A.D. is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 0 A.D. If not, see . + */ + +#ifndef INCLUDED_SCRIPTINTERFACE_JOBQUEUE +#define INCLUDED_SCRIPTINTERFACE_JOBQUEUE + +#if MSC_VERSION +# pragma warning(push, 1) +#endif +#include "js/Promise.h" +#if MSC_VERSION +# pragma warning(pop) +#endif + +#include + +class ScriptInterface; + +namespace Script +{ +void UnhandledRejectedPromise(JSContext* cx, bool, JS::HandleObject promise, + JS::PromiseRejectionHandlingState state, void*); + +class JobQueue : public JS::JobQueue +{ +public: + ~JobQueue() final = default; + + void runJobs(JSContext*) final; + +private: + JSObject* getIncumbentGlobal(JSContext* cx) final; + + bool enqueuePromiseJob(JSContext* cx, JS::HandleObject, JS::HandleObject job, JS::HandleObject, + JS::HandleObject) final; + + bool empty() const final; + + js::UniquePtr saveJobQueue(JSContext*) final; + + struct QueueElement + { + const ScriptInterface& scriptInterface; + JS::PersistentRootedObject job; + }; + using QueueType = std::queue; + QueueType m_Jobs; +}; +} + +#endif // INCLUDED_SCRIPTINTERFACE_JOBQUEUE Index: source/scriptinterface/Promises.cpp =================================================================== --- /dev/null +++ source/scriptinterface/Promises.cpp @@ -0,0 +1,115 @@ +/* Copyright (C) 2024 Wildfire Games. + * This file is part of 0 A.D. + * + * 0 A.D. is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * 0 A.D. is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 0 A.D. If not, see . + */ + +#include "precompiled.h" + +#include "Promises.h" + +#include "lib/debug.h" +#include "scriptinterface/FunctionWrapper.h" +#include "scriptinterface/ScriptInterface.h" +#include "scriptinterface/ScriptRequest.h" +#include "scriptinterface/Object.h" + +namespace Script{ +void UnhandledRejectedPromise(JSContext* cx, bool, JS::HandleObject promise, + JS::PromiseRejectionHandlingState state, void*) +{ + if (state == JS::PromiseRejectionHandlingState::Handled) + return; + + const ScriptRequest rq{cx}; + JS::RootedValue reason(cx, JS::GetPromiseResult(promise)); + + std::string asString; + ScriptFunction::Call(rq, reason, "toString", asString); + std::string stack; + Script::GetProperty(rq, reason, "stack", stack); + LOGERROR("An unhandled promise got rejected:\n%s\n%s", asString, stack); +} + +void JobQueue::runJobs(JSContext*) +{ + while (!m_Jobs.empty()) + { + QueueElement& element = m_Jobs.front(); + ScriptRequest rq{element.scriptInterface}; + JS::RootedObject localJob{rq.cx, element.job}; + m_Jobs.pop(); + + JS::RootedValue globV{rq.cx, rq.globalValue()}; + JS::RootedValue rval{rq.cx}; + JS::Call(rq.cx, globV, localJob, JS::HandleValueArray::empty(), &rval); + } +} + +JSObject* JobQueue::getIncumbentGlobal(JSContext* cx) +{ + return JS::CurrentGlobalOrNull(cx); +} + +bool JobQueue::enqueuePromiseJob(JSContext* cx, JS::HandleObject, JS::HandleObject job, JS::HandleObject, + JS::HandleObject) +{ + try + { + m_Jobs.push({ScriptRequest{cx}.GetScriptInterface(), JS::PersistentRootedObject{cx, job}}); + return true; + } + catch (...) + { + return false; + } +} + +bool JobQueue::empty() const +{ + return m_Jobs.empty(); +} + +js::UniquePtr JobQueue::saveJobQueue(JSContext*) +{ + class SavedJobQueue : public JS::JobQueue::SavedJobQueue + { + public: + SavedJobQueue(QueueType& queue) : + externQueue{queue}, + internQueue{std::move(queue)} + {} + + ~SavedJobQueue() final + { + ENSURE(externQueue.empty()); + externQueue = std::move(internQueue); + } + + private: + QueueType& externQueue; + QueueType internQueue; + }; + + try + { + return js::MakeUnique(m_Jobs); + } + catch (...) + { + return nullptr; + } +} + +} // namespace Script Index: source/scriptinterface/ScriptContext.h =================================================================== --- source/scriptinterface/ScriptContext.h +++ source/scriptinterface/ScriptContext.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2023 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -27,6 +27,10 @@ constexpr int DEFAULT_CONTEXT_SIZE = 16 * 1024 * 1024; constexpr int DEFAULT_HEAP_GROWTH_BYTES_GCTRIGGER = 2 * 1024 * 1024; +namespace Script { +class JobQueue; +} + /** * Abstraction around a SpiderMonkey JSContext. * @@ -74,6 +78,11 @@ void RegisterRealm(JS::Realm* realm); void UnRegisterRealm(JS::Realm* realm); + /** + * Runs the promise continuation. + */ + void RunJobs(); + /** * GetGeneralJSContext returns the context without starting a GC request and without * entering any compartment. It should only be used in specific situations, such as @@ -86,14 +95,15 @@ private: JSContext* m_cx; + const std::unique_ptr m_Jobqueue; void PrepareZonesForIncrementalGC() const; std::list m_Realms; int m_ContextSize; int m_HeapGrowthBytesGCTrigger; - int m_LastGCBytes; - double m_LastGCCheck; + int m_LastGCBytes{0}; + double m_LastGCCheck{0.0}; }; // Using a global object for the context is a workaround until Simulation, AI, etc, Index: source/scriptinterface/ScriptContext.cpp =================================================================== --- source/scriptinterface/ScriptContext.cpp +++ source/scriptinterface/ScriptContext.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2023 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -25,6 +25,7 @@ #include "scriptinterface/ScriptExtraHeaders.h" #include "scriptinterface/ScriptEngine.h" #include "scriptinterface/ScriptInterface.h" +#include "scriptinterface/Promises.h" void GCSliceCallbackHook(JSContext* UNUSED(cx), JS::GCProgress progress, const JS::GCDescription& UNUSED(desc)) { @@ -83,10 +84,9 @@ } ScriptContext::ScriptContext(int contextSize, int heapGrowthBytesGCTrigger): - m_LastGCBytes(0), - m_LastGCCheck(0.0f), - m_HeapGrowthBytesGCTrigger(heapGrowthBytesGCTrigger), - m_ContextSize(contextSize) + m_Jobqueue{std::make_unique()}, + m_ContextSize{contextSize}, + m_HeapGrowthBytesGCTrigger{heapGrowthBytesGCTrigger} { ENSURE(ScriptEngine::IsInitialised() && "The ScriptEngine must be initialized before constructing any ScriptContexts!"); @@ -130,6 +130,8 @@ JS::ContextOptionsRef(m_cx).setStrictMode(true); ScriptEngine::GetSingleton().RegisterContext(m_cx); + + JS::SetJobQueue(m_cx, m_Jobqueue.get()); } ScriptContext::~ScriptContext() @@ -268,6 +270,11 @@ JS_SetGCParameter(m_cx, JSGC_PER_ZONE_GC_ENABLED, false); } +void ScriptContext::RunJobs() +{ + m_Jobqueue->runJobs(m_cx); +} + void ScriptContext::PrepareZonesForIncrementalGC() const { for (JS::Realm* const& realm : m_Realms) Index: source/scriptinterface/ScriptInterface.cpp =================================================================== --- source/scriptinterface/ScriptInterface.cpp +++ source/scriptinterface/ScriptInterface.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2023 Wildfire Games. +/* Copyright (C) 2024 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -384,6 +384,7 @@ ScriptInterface::~ScriptInterface() { + m->m_context.RunJobs(); if (Threading::IsMainThread()) { if (g_ScriptStatsTable) Index: source/scriptinterface/tests/test_Promises.h =================================================================== --- /dev/null +++ source/scriptinterface/tests/test_Promises.h @@ -0,0 +1,55 @@ +/* Copyright (C) 2024 Wildfire Games. + * This file is part of 0 A.D. + * + * 0 A.D. is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * 0 A.D. is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with 0 A.D. If not, see . + */ + +#include "lib/self_test.h" + +#include "ps/CLogger.h" +#include "ps/Filesystem.h" +#include "scriptinterface/FunctionWrapper.h" +#include "scriptinterface/JSON.h" +#include "scriptinterface/Object.h" +#include "scriptinterface/Promises.h" +#include "scriptinterface/ScriptInterface.h" +#include "scriptinterface/ScriptContext.h" +#include "scriptinterface/StructuredClone.h" + +class TestPromises : public CxxTest::TestSuite +{ +public: + void test_simple_promises() + { + ScriptInterface script("Engine", "Test", g_ScriptContext); + ScriptTestSetup(script); + TS_ASSERT(script.LoadGlobalScriptFile(L"promises/simple.js")); + g_ScriptContext->RunJobs(); + + ScriptRequest rq(script); + JS::RootedValue global(rq.cx, rq.globalValue()); + ScriptFunction::CallVoid(rq, global, "endTest"); + } + + void setUp() + { + g_VFS = CreateVfs(); + TS_ASSERT_OK(g_VFS->Mount(L"", DataDir() / "mods" / "_test.scriptinterface" / "", VFS_MOUNT_MUST_EXIST)); + } + + void tearDown() + { + g_VFS.reset(); + } +}; Index: source/simulation2/Simulation2.cpp =================================================================== --- source/simulation2/Simulation2.cpp +++ source/simulation2/Simulation2.cpp @@ -399,6 +399,8 @@ UpdateComponents(m_SimContext, turnLengthFixed, commands); + scriptInterface.GetContext().RunJobs(); + if (m_EnableSerializationTest || startRejoinTest) { if (startRejoinTest)