diff --git a/src/client/component/updater.cpp b/src/client/component/updater.cpp new file mode 100644 index 00000000..512b619b --- /dev/null +++ b/src/client/component/updater.cpp @@ -0,0 +1,469 @@ +#include +#include "loader/component_loader.hpp" + +#include "scheduler.hpp" +#include "dvars.hpp" +#include "updater.hpp" + +#include "version.h" + +#include "game/game.hpp" +#include "game/dvars.hpp" + +#include +#include +#include +#include +#include +#include + +#define MASTER "https://master.fed0001.xyz/h1-mod/" + +#define FILES_PATH "files.json" +#define FILES_PATH_DEV "files-dev.json" + +#define DATA_PATH "data/" +#define DATA_PATH_DEV "data-dev/" + +#define ERR_UPDATE_CHECK_FAIL "Failed to check for updates" +#define ERR_DOWNLOAD_FAIL "Failed to download file " +#define ERR_WRITE_FAIL "Failed to write file " + +#define BINARY_NAME "h1-mod.exe" + +namespace updater +{ + namespace + { + game::dvar_t* cl_auto_update; + bool has_tried_update = false; + + struct status + { + bool done; + bool success; + }; + + struct file_data + { + std::string name; + std::string data; + }; + + struct update_data_t + { + bool restart_required{}; + bool cancelled{}; + status check{}; + status download{}; + std::string error{}; + std::string current_file{}; + std::vector required_files{}; + }; + + utils::concurrency::container update_data; + + std::string get_branch() + { + return GIT_BRANCH; + } + + std::string select(const std::string& main, const std::string& develop) + { + if (get_branch() == "develop") + { + return develop; + } + + return main; + } + + std::string get_data_path() + { + if (get_branch() == "develop") + { + return DATA_PATH_DEV; + } + + return DATA_PATH; + } + + void set_update_check_status(bool done, bool success, const std::string& error = {}) + { + update_data.access([done, success, error](update_data_t& data_) + { + data_.check.done = done; + data_.check.success = success; + data_.error = error; + }); + } + + void set_update_download_status(bool done, bool success, const std::string& error = {}) + { + update_data.access([done, success, error](update_data_t& data_) + { + data_.download.done = done; + data_.download.success = success; + data_.error = error; + }); + } + + bool check_file(const std::string& name, const std::string& sha) + { + std::string data; + if (!utils::io::read_file(name, &data)) + { + return false; + } + + if (utils::cryptography::sha1::compute(data, true) != sha) + { + return false; + } + + return true; + } + + std::string load_binary_name() + { + // utils::nt::library self; + // return self.get_name(); + // returns the game's name and not the client's + + return BINARY_NAME; + } + + std::string get_binary_name() + { + static const auto name = load_binary_name(); + return name; + } + + std::optional download_file(const std::string& name) + { + return utils::http::get_data(MASTER + select(DATA_PATH, DATA_PATH_DEV) + name); + } + + bool is_update_cancelled() + { + return update_data.access([](update_data_t& data_) + { + return data_.cancelled; + }); + } + + bool write_file(const std::string& name, const std::string& data) + { + if (get_binary_name() == name && + utils::io::file_exists(name) && + !utils::io::move_file(name, name + ".old")) + { + return false; + } + +#ifdef DEBUG + return utils::io::write_file("update_test/" + name, data); +#else + return utils::io::write_file(name, data); +#endif + } + + void delete_old_file() + { + utils::io::remove_file(get_binary_name() + ".old"); + } + + void reset_data() + { + update_data.access([](update_data_t& data_) + { + data_ = {}; + }); + } + + std::string get_mode_flag() + { + if (game::environment::is_mp()) + { + return "-multiplayer"; + } + + if (game::environment::is_sp()) + { + return "-singleplayer"; + } + + return {}; + } + } + + // workaround + void relaunch() + { + if (!utils::io::file_exists(BINARY_NAME)) + { + utils::nt::terminate(0); + return; + } + + STARTUPINFOA startup_info; + PROCESS_INFORMATION process_info; + + ZeroMemory(&startup_info, sizeof(startup_info)); + ZeroMemory(&process_info, sizeof(process_info)); + startup_info.cb = sizeof(startup_info); + + char current_dir[MAX_PATH]; + GetCurrentDirectoryA(sizeof(current_dir), current_dir); + + char buf[1024] = {0}; + const auto command_line = utils::string::va("%s %s", GetCommandLineA(), get_mode_flag().data()); + strcpy_s(buf, 1024, command_line); + + CreateProcess(BINARY_NAME, buf, nullptr, nullptr, false, NULL, nullptr, current_dir, + &startup_info, &process_info); + + if (process_info.hThread && process_info.hThread != INVALID_HANDLE_VALUE) CloseHandle(process_info.hThread); + if (process_info.hProcess && process_info.hProcess != INVALID_HANDLE_VALUE) CloseHandle(process_info.hProcess); + + utils::nt::terminate(0); + } + + void set_has_tried_update(bool tried) + { + has_tried_update = tried; + } + + bool get_has_tried_update() + { + return has_tried_update; + } + + bool auto_updates_enabled() + { + return cl_auto_update->current.enabled; + } + + bool is_update_check_done() + { + return update_data.access([](update_data_t& data_) + { + return data_.check.done; + }); + } + + bool is_update_download_done() + { + return update_data.access([](update_data_t& data_) + { + return data_.download.done; + }); + } + + bool get_update_check_status() + { + return update_data.access([](update_data_t& data_) + { + return data_.check.success; + }); + } + + bool get_update_download_status() + { + return update_data.access([](update_data_t& data_) + { + return data_.download.success; + }); + } + + bool is_update_available() + { + return update_data.access([](update_data_t& data_) + { + return data_.required_files.size() > 0; + }); + } + + bool is_restart_required() + { + return update_data.access([](update_data_t& data_) + { + return data_.restart_required; + }); + } + + std::string get_last_error() + { + return update_data.access([](update_data_t& data_) + { + return data_.error; + }); + } + + std::string get_current_file() + { + return update_data.access([](update_data_t& data_) + { + return data_.current_file; + }); + } + + void cancel_update() + { +#ifdef DEBUG + printf("[Updater] Cancelling update\n"); +#endif + + return update_data.access([](update_data_t& data_) + { + data_.cancelled = true; + }); + } + + void start_update_check() + { + cancel_update(); + reset_data(); + +#ifdef DEBUG + printf("[Updater] starting update check\n"); +#endif + + scheduler::once([]() + { + const auto files_data = utils::http::get_data(MASTER + select(FILES_PATH, FILES_PATH_DEV)); + + if (is_update_cancelled()) + { + reset_data(); + return; + } + + if (!files_data.has_value()) + { + set_update_check_status(true, false, ERR_UPDATE_CHECK_FAIL); + return; + } + + rapidjson::Document j; + j.Parse(files_data.value().data()); + + if (!j.IsArray()) + { + set_update_check_status(true, false, ERR_UPDATE_CHECK_FAIL); + return; + } + + std::vector required_files; + + const auto files = j.GetArray(); + for (const auto& file : files) + { + if (!file.IsArray() || file.Size() != 3 || !file[0].IsString() || !file[2].IsString()) + { + continue; + } + + const auto name = file[0].GetString(); + const auto sha = file[2].GetString(); + + if (!check_file(name, sha)) + { + if (get_binary_name() == name) + { + update_data.access([](update_data_t& data_) + { + data_.restart_required = true; + }); + } + +#ifdef DEBUG + printf("[Updater] need file %s\n", name); +#endif + + required_files.push_back(name); + } + } + + update_data.access([&required_files](update_data_t& data_) + { + data_.check.done = true; + data_.check.success = true; + data_.required_files = required_files; + }); + }, scheduler::pipeline::async); + } + + void start_update_download() + { +#ifdef DEBUG + printf("[Updater] starting update download\n"); +#endif + + if (!is_update_check_done() || !get_update_check_status() || is_update_cancelled()) + { + return; + } + + scheduler::once([]() + { + const auto required_files = update_data.access>([](update_data_t& data_) + { + return data_.required_files; + }); + + std::vector downloads; + + for (const auto& file : required_files) + { + update_data.access([file](update_data_t& data_) + { + data_.current_file = file; + }); + +#ifdef DEBUG + printf("[Updater] downloading file %s\n", file.data()); +#endif + + const auto data = download_file(file); + + if (is_update_cancelled()) + { + reset_data(); + return; + } + + if (!data.has_value()) + { + set_update_download_status(true, false, ERR_DOWNLOAD_FAIL + file); + return; + } + + downloads.push_back({file, data.value()}); + } + + for (const auto& download : downloads) + { + if (!write_file(download.name, download.data)) + { + set_update_download_status(true, false, ERR_WRITE_FAIL + download.name); + return; + } + } + + set_update_download_status(true, true); + }, scheduler::pipeline::async); + } + + class component final : public component_interface + { + public: + void post_unpack() override + { + delete_old_file(); + cl_auto_update = dvars::register_bool("cg_auto_update", true, game::DVAR_FLAG_SAVED, true); + } + }; +} + +REGISTER_COMPONENT(updater::component) diff --git a/src/client/component/updater.hpp b/src/client/component/updater.hpp new file mode 100644 index 00000000..9a3dd45e --- /dev/null +++ b/src/client/component/updater.hpp @@ -0,0 +1,26 @@ +#pragma once + +namespace updater +{ + void relaunch(); + + void set_has_tried_update(bool tried); + bool get_has_tried_update(); + bool auto_updates_enabled(); + + bool is_update_available(); + bool is_update_check_done(); + bool get_update_check_status(); + + bool is_update_download_done(); + bool get_update_download_status(); + + bool is_restart_required(); + + std::string get_last_error(); + std::string get_current_file(); + + void start_update_check(); + void start_update_download(); + void cancel_update(); +} \ No newline at end of file diff --git a/src/client/game/ui_scripting/lua/context.cpp b/src/client/game/ui_scripting/lua/context.cpp index def427e0..afa00d5d 100644 --- a/src/client/game/ui_scripting/lua/context.cpp +++ b/src/client/game/ui_scripting/lua/context.cpp @@ -7,6 +7,7 @@ #include "../../../component/ui_scripting.hpp" #include "../../../component/command.hpp" +#include "../../../component/updater.hpp" #include "component/game_console.hpp" #include "component/scheduler.hpp" @@ -141,12 +142,35 @@ namespace ui_scripting::lua state["LUI"] = state["luiglobals"]["LUI"]; state["Engine"] = state["luiglobals"]["Engine"]; state["Game"] = state["luiglobals"]["Game"]; + + auto updater_table = sol::table::create(state.lua_state()); + + updater_table["relaunch"] = updater::relaunch; + + updater_table["sethastriedupdate"] = updater::set_has_tried_update; + updater_table["gethastriedupdate"] = updater::get_has_tried_update; + updater_table["autoupdatesenabled"] = updater::auto_updates_enabled; + + updater_table["startupdatecheck"] = updater::start_update_check; + updater_table["isupdatecheckdone"] = updater::is_update_check_done; + updater_table["getupdatecheckstatus"] = updater::get_update_check_status; + updater_table["isupdateavailable"] = updater::is_update_available; + + updater_table["startupdatedownload"] = updater::start_update_download; + updater_table["isupdatedownloaddone"] = updater::is_update_download_done; + updater_table["getupdatedownloadstatus"] = updater::get_update_download_status; + updater_table["cancelupdate"] = updater::cancel_update; + updater_table["isrestartrequired"] = updater::is_restart_required; + + updater_table["getlasterror"] = updater::get_last_error; + updater_table["getcurrentfile"] = updater::get_current_file; + + state["updater"] = updater_table; } } - context::context(std::string folder) - : folder_(std::move(folder)) - , scheduler_(state_) + context::context(std::string data, script_type type) + : scheduler_(state_) { this->state_.open_libraries(sol::lib::base, sol::lib::package, @@ -156,27 +180,37 @@ namespace ui_scripting::lua sol::lib::math, sol::lib::table); - this->state_["include"] = [this](const std::string& file) - { - this->load_script(file); - }; - - sol::function old_require = this->state_["require"]; - auto base_path = utils::string::replace(this->folder_, "/", ".") + "."; - this->state_["require"] = [base_path, old_require](const std::string& path) - { - return old_require(base_path + path); - }; - - this->state_["scriptdir"] = [this]() - { - return this->folder_; - }; - setup_types(this->state_, this->scheduler_); - printf("Loading ui script '%s'\n", this->folder_.data()); - this->load_script("__init__"); + if (type == script_type::file) + { + this->folder_ = data; + + this->state_["include"] = [this](const std::string& file) + { + this->load_script(file); + }; + + sol::function old_require = this->state_["require"]; + auto base_path = utils::string::replace(this->folder_, "/", ".") + "."; + this->state_["require"] = [base_path, old_require](const std::string& path) + { + return old_require(base_path + path); + }; + + this->state_["scriptdir"] = [this]() + { + return this->folder_; + }; + + printf("Loading ui script '%s'\n", this->folder_.data()); + this->load_script("__init__"); + } + + if (type == script_type::code) + { + handle_error(this->state_.safe_script(data, &sol::script_pass_on_error)); + } } context::~context() diff --git a/src/client/game/ui_scripting/lua/context.hpp b/src/client/game/ui_scripting/lua/context.hpp index 0876d3df..866c601e 100644 --- a/src/client/game/ui_scripting/lua/context.hpp +++ b/src/client/game/ui_scripting/lua/context.hpp @@ -10,10 +10,16 @@ namespace ui_scripting::lua { + enum script_type + { + file, + code + }; + class context { public: - context(std::string folder); + context(std::string folder, script_type type); ~context(); context(context&&) noexcept = delete; diff --git a/src/client/game/ui_scripting/lua/engine.cpp b/src/client/game/ui_scripting/lua/engine.cpp index 7e6d7f6c..93713530 100644 --- a/src/client/game/ui_scripting/lua/engine.cpp +++ b/src/client/game/ui_scripting/lua/engine.cpp @@ -11,6 +11,9 @@ namespace ui_scripting::lua::engine { namespace { + const auto lui_common = utils::nt::load_resource(LUI_COMMON); + const auto lui_updater = utils::nt::load_resource(LUI_UPDATER); + auto& get_scripts() { static std::vector> scripts{}; @@ -30,16 +33,25 @@ namespace ui_scripting::lua::engine { if (std::filesystem::is_directory(script) && utils::io::file_exists(script + "/__init__.lua")) { - get_scripts().push_back(std::make_unique(script)); + get_scripts().push_back(std::make_unique(script, script_type::file)); } } } + + void load_code(const std::string& code) + { + get_scripts().push_back(std::make_unique(code, script_type::code)); + } } void start() { clear_converted_functions(); get_scripts().clear(); + + load_code(lui_common); + load_code(lui_updater); + load_scripts(game_module::get_host_module().get_folder() + "/data/ui_scripts/"); load_scripts("h1-mod/ui_scripts/"); load_scripts("data/ui_scripts/"); diff --git a/src/client/resource.hpp b/src/client/resource.hpp index f80f9b48..adc92d9c 100644 --- a/src/client/resource.hpp +++ b/src/client/resource.hpp @@ -15,3 +15,6 @@ #define RUNNER 307 #define ICON_IMAGE 308 + +#define LUI_COMMON 309 +#define LUI_UPDATER 310 diff --git a/src/client/resource.rc b/src/client/resource.rc index 6866f241..c53c106a 100644 --- a/src/client/resource.rc +++ b/src/client/resource.rc @@ -118,6 +118,9 @@ RUNNER RCDATA "../../build/bin/x64/Release/runner.exe" ICON_IMAGE RCDATA "resources/icon.png" +LUI_COMMON RCDATA "resources/ui_scripts/common.lua" +LUI_UPDATER RCDATA "resources/ui_scripts/updater.lua" + #endif // English (United States) resources ///////////////////////////////////////////////////////////////////////////// diff --git a/src/client/resources/ui_scripts/common.lua b/src/client/resources/ui_scripts/common.lua new file mode 100644 index 00000000..8f68e96d --- /dev/null +++ b/src/client/resources/ui_scripts/common.lua @@ -0,0 +1,164 @@ +menucallbacks = {} +originalmenus = {} +stack = {} + +LUI.MenuBuilder.m_types_build["generic_waiting_popup_"] = function (menu, event) + local oncancel = stack.oncancel + local popup = LUI.MenuBuilder.BuildRegisteredType("waiting_popup", { + message_text = stack.text, + isLiveWithCancel = true, + cancel_func = function(...) + local args = {...} + oncancel() + LUI.FlowManager.RequestLeaveMenu(args[1]) + end + }) + + local listchildren = popup:getChildById("LUIHorizontalList"):getchildren() + local children = listchildren[2]:getchildren() + popup.text = children[2] + + stack = { + ret = popup + } + + return popup +end + +LUI.MenuBuilder.m_types_build["generic_yes_no_popup_"] = function() + local callback = stack.callback + local popup = LUI.MenuBuilder.BuildRegisteredType("generic_yesno_popup", { + popup_title = stack.title, + message_text = stack.text, + yes_action = function() + callback(true) + end, + no_action = function() + callback(false) + end + }) + + stack = { + ret = popup + } + + return popup +end + +LUI.MenuBuilder.m_types_build["generic_confirmation_popup_"] = function() + local popup = LUI.MenuBuilder.BuildRegisteredType( "generic_confirmation_popup", { + cancel_will_close = false, + popup_title = stack.title, + message_text = stack.text, + button_text = stack.buttontext, + confirmation_action = stack.callback + }) + + stack = { + ret = popup + } + + return stack.ret +end + +LUI.onmenuopen = function(name, callback) + if (not LUI.MenuBuilder.m_types_build[name]) then + return + end + + if (not menucallbacks[name]) then + menucallbacks[name] = {} + end + + table.insert(menucallbacks[name], callback) + + if (not originalmenus[name]) then + originalmenus[name] = LUI.MenuBuilder.m_types_build[name] + LUI.MenuBuilder.m_types_build[name] = function(...) + local args = {...} + local menu = originalmenus[name](table.unpack(args)) + + for k, v in luiglobals.next, menucallbacks[name] do + v(menu, table.unpack(args)) + end + + return menu + end + end +end + +local addoptionstextinfo = LUI.Options.AddOptionTextInfo +LUI.Options.AddOptionTextInfo = function(menu) + local result = addoptionstextinfo(menu) + menu.optionTextInfo = result + return result +end + +LUI.addmenubutton = function(name, data) + LUI.onmenuopen(name, function(menu) + if (not menu.list) then + return + end + + local button = menu:AddButton(data.text, data.callback, nil, true, nil, { + desc_text = data.description + }) + + local buttonlist = menu:getChildById(menu.type .. "_list") + + if (data.id) then + button.id = data.id + end + + if (data.index) then + buttonlist:removeElement(button) + buttonlist:insertElement(button, data.index) + end + + local hintbox = menu.optionTextInfo + menu:removeElement(hintbox) + + LUI.Options.InitScrollingList(menu.list, nil) + menu.optionTextInfo = LUI.Options.AddOptionTextInfo(menu) + end) +end + +LUI.openmenu = function(menu, args) + stack = args + LUI.FlowManager.RequestAddMenu(nil, menu) + return stack.ret +end + +LUI.openpopupmenu = function(menu, args) + stack = args + LUI.FlowManager.RequestPopupMenu(nil, menu) + return stack.ret +end + +LUI.yesnopopup = function(data) + for k, v in luiglobals.next, data do + stack[k] = v + end + LUI.FlowManager.RequestPopupMenu(nil, "generic_yes_no_popup_") + return stack.ret +end + +LUI.confirmationpopup = function(data) + for k, v in luiglobals.next, data do + stack[k] = v + end + LUI.FlowManager.RequestPopupMenu(nil, "generic_confirmation_popup_") + return stack.ret +end + +function userdata_:getchildren() + local children = {} + local first = self:getFirstChild() + + while (first) do + table.insert(children, first) + first = first:getNextSibling() + end + + return children +end diff --git a/src/client/resources/ui_scripts/updater.lua b/src/client/resources/ui_scripts/updater.lua new file mode 100644 index 00000000..21d289d4 --- /dev/null +++ b/src/client/resources/ui_scripts/updater.lua @@ -0,0 +1,156 @@ +updatecancelled = false +taskinterval = 100 + +function startupdatecheck(popup, autoclose) + updatecancelled = false + + local callback = function() + if (not updater.getupdatecheckstatus()) then + if (autoclose) then + LUI.FlowManager.RequestLeaveMenu(popup) + return + end + + popup.text:setText("Error: " .. updater.getlasterror()) + return + end + + if (not updater.isupdateavailable()) then + if (autoclose) then + LUI.FlowManager.RequestLeaveMenu(popup) + return + end + + popup.text:setText("No updates available") + return + end + + LUI.yesnopopup({ + title = "NOTICE", + text = "An update is available, proceed with installation?", + callback = function(result) + if (result) then + startupdatedownload(popup, autoclose) + else + LUI.FlowManager.RequestLeaveMenu(popup) + end + end + }) + end + + updater.startupdatecheck() + createtask({ + done = updater.isupdatecheckdone, + cancelled = isupdatecancelled, + callback = callback, + interval = taskinterval + }) +end + +function startupdatedownload(popup, autoclose) + updater.startupdatedownload() + + local textupdate = nil + local previousfile = nil + textupdate = game:oninterval(function() + local file = updater.getcurrentfile() + if (file == previousfile) then + return + end + + file = previousfile + popup.text:setText("Downloading file " .. updater.getcurrentfile() .. "...") + end, 10) + + local callback = function() + textupdate:clear() + + if (not updater.getupdatedownloadstatus()) then + if (autoclose) then + LUI.FlowManager.RequestLeaveMenu(popup) + return + end + + popup.text:setText("Error: " .. updater.getlasterror()) + return + end + + popup.text:setText("Update successful") + + if (updater.isrestartrequired()) then + LUI.confirmationpopup({ + title = "RESTART REQUIRED", + text = "Update requires restart", + buttontext = "RESTART", + callback = function() + updater.relaunch() + end + })-- + end + + if (autoclose) then + LUI.FlowManager.RequestLeaveMenu(popup) + end + end + + createtask({ + done = updater.isupdatedownloaddone, + cancelled = isupdatecancelled, + callback = callback, + interval = taskinterval + }) +end + +function updaterpopup(oncancel) + return LUI.openpopupmenu("generic_waiting_popup_", { + oncancel = oncancel, + withcancel = true, + text = "Checking for updates..." + }) +end + +function createtask(data) + local interval = nil + interval = game:oninterval(function() + if (data.cancelled()) then + interval:clear() + return + end + + if (data.done()) then + interval:clear() + data.callback() + end + end, data.interval) + return interval +end + +function isupdatecancelled() + return updatecancelled +end + +function tryupdate(autoclose) + updatecancelled = false + local popup = updaterpopup(function() + updater.cancelupdate() + updatecancelled = true + end) + + startupdatecheck(popup, autoclose) +end + +function tryautoupdate() + if (not updater.autoupdatesenabled()) then + return + end + + if (not updater.gethastriedupdate()) then + game:ontimeout(function() + updater.sethastriedupdate(true) + tryupdate(true) + end, 100) + end +end + +LUI.onmenuopen("mp_main_menu", tryautoupdate) +LUI.onmenuopen("main_lockout", tryautoupdate) \ No newline at end of file