diff --git a/Source/Core/DolphinQt/Updater.cpp b/Source/Core/DolphinQt/Updater.cpp index 0827c0c104..47eb57ef39 100644 --- a/Source/Core/DolphinQt/Updater.cpp +++ b/Source/Core/DolphinQt/Updater.cpp @@ -3,6 +3,7 @@ #include "DolphinQt/Updater.h" +#include #include #include @@ -41,6 +42,16 @@ void Updater::CheckForUpdate() void Updater::OnUpdateAvailable(const NewVersionInformation& info) { + if (std::getenv("DOLPHIN_UPDATE_SERVER_URL")) + { + TriggerUpdate(info, AutoUpdateChecker::RestartMode::RESTART_AFTER_UPDATE); + RunOnObject(m_parent, [this] { + m_parent->close(); + return 0; + }); + return; + } + bool later = false; std::optional choice = RunOnObject(m_parent, [&] { diff --git a/Source/Core/UICommon/AutoUpdate.cpp b/Source/Core/UICommon/AutoUpdate.cpp index b67c499c14..a638ae33a6 100644 --- a/Source/Core/UICommon/AutoUpdate.cpp +++ b/Source/Core/UICommon/AutoUpdate.cpp @@ -3,6 +3,7 @@ #include "UICommon/AutoUpdate.h" +#include #include #include @@ -19,12 +20,13 @@ #ifdef _WIN32 #include +#else +#include +#include #endif #ifdef __APPLE__ #include -#include -#include #endif #if defined(_WIN32) || defined(__APPLE__) @@ -160,6 +162,23 @@ static std::string GetPlatformID() #endif } +static std::string GetUpdateServerUrl() +{ + auto server_url = std::getenv("DOLPHIN_UPDATE_SERVER_URL"); + if (server_url) + return server_url; + return "https://dolphin-emu.org"; +} + +static u32 GetOwnProcessId() +{ +#ifdef _WIN32 + return GetCurrentProcessId(); +#else + return getpid(); +#endif +} + void AutoUpdateChecker::CheckForUpdate(std::string_view update_track, std::string_view hash_override, const CheckType check_type) { @@ -172,7 +191,7 @@ void AutoUpdateChecker::CheckForUpdate(std::string_view update_track, #endif std::string_view version_hash = hash_override.empty() ? Common::GetScmRevGitStr() : hash_override; - std::string url = fmt::format("https://dolphin-emu.org/update/check/v1/{}/{}/{}", update_track, + std::string url = fmt::format("{}/update/check/v1/{}/{}/{}", GetUpdateServerUrl(), update_track, version_hash, GetPlatformID()); const bool is_manual_check = check_type == CheckType::Manual; @@ -215,7 +234,15 @@ void AutoUpdateChecker::CheckForUpdate(std::string_view update_track, // TODO: generate the HTML changelog from the JSON information. nvi.changelog_html = GenerateChangelog(obj["changelog"].get()); - OnUpdateAvailable(nvi); + if (std::getenv("DOLPHIN_UPDATE_TEST_DONE")) + { + // We are at end of updater test flow, send a message to server, which will kill us. + req.Get(fmt::format("{}/update-test-done/{}", GetUpdateServerUrl(), GetOwnProcessId())); + } + else + { + OnUpdateAvailable(nvi); + } } void AutoUpdateChecker::TriggerUpdate(const AutoUpdateChecker::NewVersionInformation& info, @@ -234,11 +261,7 @@ void AutoUpdateChecker::TriggerUpdate(const AutoUpdateChecker::NewVersionInforma updater_flags["this-manifest-url"] = info.this_manifest_url; updater_flags["next-manifest-url"] = info.next_manifest_url; updater_flags["content-store-url"] = info.content_store_url; -#ifdef _WIN32 - updater_flags["parent-pid"] = std::to_string(GetCurrentProcessId()); -#else - updater_flags["parent-pid"] = std::to_string(getpid()); -#endif + updater_flags["parent-pid"] = std::to_string(GetOwnProcessId()); updater_flags["install-base-path"] = File::GetExeDirectory(); updater_flags["log-file"] = File::GetUserPath(D_LOGS_IDX) + UPDATER_LOG_FILE; diff --git a/Source/Core/UpdaterCommon/UI.h b/Source/Core/UpdaterCommon/UI.h index 86b5aa7013..3b441ea568 100644 --- a/Source/Core/UpdaterCommon/UI.h +++ b/Source/Core/UpdaterCommon/UI.h @@ -29,4 +29,6 @@ void Init(); void Sleep(int seconds); void WaitForPID(u32 pid); void LaunchApplication(std::string path); + +bool IsTestMode(); } // namespace UI diff --git a/Source/Core/UpdaterCommon/UpdaterCommon.cpp b/Source/Core/UpdaterCommon/UpdaterCommon.cpp index 0d50603139..426943ce83 100644 --- a/Source/Core/UpdaterCommon/UpdaterCommon.cpp +++ b/Source/Core/UpdaterCommon/UpdaterCommon.cpp @@ -38,6 +38,10 @@ const std::array UPDATE_PUB_KEY = { 0x2a, 0xb3, 0xd1, 0xdc, 0x6e, 0xf5, 0x07, 0xf6, 0xa0, 0x6c, 0x7c, 0x54, 0xdf, 0x54, 0xf4, 0x42, 0x80, 0xa6, 0x28, 0x8b, 0x6d, 0x70, 0x14, 0xb5, 0x4c, 0x34, 0x95, 0x20, 0x4d, 0xd4, 0xd3, 0x5d}; +// The private key for UPDATE_PUB_KEY_TEST is in Tools/test-updater.py +const std::array UPDATE_PUB_KEY_TEST = { + 0x0c, 0x5f, 0xdc, 0xd1, 0x15, 0x71, 0xfb, 0x86, 0x4f, 0x9e, 0x6d, 0xe6, 0x65, 0x39, 0x43, 0xe1, + 0x9e, 0xe0, 0x9b, 0x28, 0xc9, 0x1a, 0x60, 0xb7, 0x67, 0x1c, 0xf3, 0xf6, 0xca, 0x1b, 0xdd, 0x1a}; // Where to log updater output. static FILE* log_fp = stderr; @@ -163,8 +167,9 @@ bool VerifySignature(const std::string& data, const std::string& b64_signature) return false; } + const auto& pub_key = UI::IsTestMode() ? UPDATE_PUB_KEY_TEST : UPDATE_PUB_KEY; return ed25519_verify(signature, reinterpret_cast(data.data()), data.size(), - UPDATE_PUB_KEY.data()); + pub_key.data()); } void FlushLog() diff --git a/Source/Core/WinUpdater/Platform.cpp b/Source/Core/WinUpdater/Platform.cpp index 1cfa6b4327..0a2b62c5f9 100644 --- a/Source/Core/WinUpdater/Platform.cpp +++ b/Source/Core/WinUpdater/Platform.cpp @@ -194,9 +194,12 @@ static bool VCRuntimeUpdate(const BuildInfo& build_info) Common::ScopeGuard redist_deleter([&] { File::Delete(redist_path_u8); }); - // The installer also supports /passive and /quiet. We pass neither to allow the user to see and - // interact with the installer. + // The installer also supports /passive and /quiet. We normally pass neither (the + // exception being test automation) to allow the user to see and interact with the installer. std::wstring cmdline = redist_path.filename().wstring() + L" /install /norestart"; + if (UI::IsTestMode()) + cmdline += L" /passive /quiet"; + STARTUPINFOW startup_info{.cb = sizeof(startup_info)}; PROCESS_INFORMATION process_info; if (!CreateProcessW(redist_path.c_str(), cmdline.data(), nullptr, nullptr, TRUE, 0, nullptr, @@ -213,7 +216,8 @@ static bool VCRuntimeUpdate(const BuildInfo& build_info) CloseHandle(process_info.hProcess); // NOTE: Some nonzero exit codes can still be considered success (e.g. if installation was // bypassed because the same version already installed). - return has_exit_code && exit_code == EXIT_SUCCESS; + return has_exit_code && + (exit_code == ERROR_SUCCESS || exit_code == ERROR_SUCCESS_REBOOT_REQUIRED); } static BuildVersion CurrentOSVersion() @@ -287,11 +291,16 @@ bool CheckBuildInfo(const BuildInfos& build_infos) // Check if application being launched needs more recent version of VC Redist. If so, download // latest updater and execute it. auto vc_check = VCRuntimeVersionCheck(build_infos); - if (vc_check.status != VersionCheckStatus::NothingToDo) + const auto is_test_mode = UI::IsTestMode(); + if (vc_check.status != VersionCheckStatus::NothingToDo || is_test_mode) { - // Don't bother checking status of the install itself, just check if we actually see the new - // version. - VCRuntimeUpdate(build_infos.next); + auto update_ok = VCRuntimeUpdate(build_infos.next); + if (!update_ok && is_test_mode) + { + // For now, only check return value when test automation is running. + // The vc_redist exe may return other non-zero status that we don't check for, yet. + return false; + } vc_check = VCRuntimeVersionCheck(build_infos); if (vc_check.status == VersionCheckStatus::UpdateRequired) { diff --git a/Source/Core/WinUpdater/WinUI.cpp b/Source/Core/WinUpdater/WinUI.cpp index 1671f43457..8e3e7c375d 100644 --- a/Source/Core/WinUpdater/WinUI.cpp +++ b/Source/Core/WinUpdater/WinUI.cpp @@ -3,12 +3,14 @@ #include "UpdaterCommon/UI.h" +#include #include #include #include #include #include +#include #include #include @@ -251,11 +253,34 @@ void Stop() ui_thread.join(); } +bool IsTestMode() +{ + return std::getenv("DOLPHIN_UPDATE_SERVER_URL") != nullptr; +} + void LaunchApplication(std::string path) { - // Indirectly start the application via explorer. This effectively drops admin priviliges because - // explorer is running as current user. - ShellExecuteW(nullptr, nullptr, L"explorer.exe", UTF8ToWString(path).c_str(), nullptr, SW_SHOW); + const auto wpath = UTF8ToWString(path); + if (IsUserAnAdmin()) + { + // Indirectly start the application via explorer. This effectively drops admin privileges + // because explorer is running as current user. + ShellExecuteW(nullptr, nullptr, L"explorer.exe", wpath.c_str(), nullptr, SW_SHOW); + } + else + { + std::wstring cmdline = wpath; + STARTUPINFOW startup_info{.cb = sizeof(startup_info)}; + PROCESS_INFORMATION process_info; + if (IsTestMode()) + SetEnvironmentVariableA("DOLPHIN_UPDATE_TEST_DONE", "1"); + if (CreateProcessW(wpath.c_str(), cmdline.data(), nullptr, nullptr, TRUE, 0, nullptr, nullptr, + &startup_info, &process_info)) + { + CloseHandle(process_info.hThread); + CloseHandle(process_info.hProcess); + } + } } void Sleep(int sleep) diff --git a/Tools/test-updater.py b/Tools/test-updater.py new file mode 100644 index 0000000000..4cb5d07d46 --- /dev/null +++ b/Tools/test-updater.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# requirements: pycryptodome +from Crypto.PublicKey import ECC +from Crypto.Signature import eddsa +from hashlib import sha256 +from pathlib import Path +import base64 +import configparser +import gzip +import http.server +import json +import os +import shutil +import socketserver +import subprocess +import sys +import tempfile +import threading +import time + +UPDATE_KEY_TEST = ECC.construct( + curve="Ed25519", + seed=bytes.fromhex( + "543a581db60008bbb978a464e136d686dbc9d594119e928b5276bece3d583d81" + ), +) + +HTTP_SERVER_ADDR = ("localhost", 8042) +DOLPHIN_UPDATE_SERVER_URL = f"http://{HTTP_SERVER_ADDR[0]}:{HTTP_SERVER_ADDR[1]}" + + +class Manifest: + def __init__(self, path: Path): + self.path = path + self.entries = {} + for p in self.path.glob("**/*.*"): + if not p.is_file(): + continue + digest = sha256(p.read_bytes()).digest()[:0x10].hex() + self.entries[digest] = p.relative_to(self.path).as_posix() + + def get_signed(self): + manifest = "".join( + f"{name}\t{digest}\n" for digest, name in self.entries.items() + ) + manifest = manifest.encode("utf-8") + sig = eddsa.new(UPDATE_KEY_TEST, "rfc8032").sign(manifest) + manifest += b"\n" + base64.b64encode(sig) + b"\n" + return gzip.compress(manifest) + + def get_path(self, digest): + return self.path.joinpath(self.entries.get(digest)) + + +class HTTPRequestHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path.startswith("/update/check/v1/updater-test"): + self.send_response(200) + self.end_headers() + self.wfile.write( + bytes( + json.dumps( + { + "status": "outdated", + "content-store": DOLPHIN_UPDATE_SERVER_URL + "/content/", + "changelog": [], + "old": {"manifest": DOLPHIN_UPDATE_SERVER_URL + "/old"}, + "new": { + "manifest": DOLPHIN_UPDATE_SERVER_URL + "/new", + "name": "updater-test", + "hash": bytes(range(32)).hex(), + }, + } + ), + "utf-8", + ) + ) + elif self.path == "/old": + self.send_response(200) + self.end_headers() + self.wfile.write(self.current.get_signed()) + elif self.path == "/new": + self.send_response(200) + self.end_headers() + self.wfile.write(self.next.get_signed()) + elif self.path.startswith("/content/"): + self.send_response(200) + self.end_headers() + digest = "".join(self.path[len("/content/") :].split("/")) + path = self.next.get_path(digest) + self.wfile.write(gzip.compress(path.read_bytes())) + elif self.path.startswith("/update-test-done/"): + self.send_response(200) + self.end_headers() + HTTPRequestHandler.dolphin_pid = int(self.path[len("/update-test-done/") :]) + self.done.set() + + +def http_server(): + with socketserver.TCPServer(HTTP_SERVER_ADDR, HTTPRequestHandler) as httpd: + httpd.serve_forever() + + +def create_entries_in_ini(ini_path: Path, entries: dict): + config = configparser.ConfigParser() + if ini_path.exists(): + config.read(ini_path) + else: + ini_path.parent.mkdir(parents=True, exist_ok=True) + + for section, options in entries.items(): + if not config.has_section(section): + config.add_section(section) + for option, value in options.items(): + config.set(section, option, value) + + with ini_path.open("w") as f: + config.write(f) + + +if __name__ == "__main__": + dolphin_bin_path = Path(sys.argv[1]) + + threading.Thread(target=http_server, daemon=True).start() + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_dir = Path(tmp_dir) + + tmp_dolphin = tmp_dir.joinpath("dolphin") + print(f"install to {tmp_dolphin}") + shutil.copytree(dolphin_bin_path.parent, tmp_dolphin) + tmp_dolphin.joinpath("portable.txt").touch() + create_entries_in_ini( + tmp_dolphin.joinpath("User/Config/Dolphin.ini"), + { + "Analytics": {"Enabled": "False", "PermissionAsked": "True"}, + "AutoUpdate": {"UpdateTrack": "updater-test"}, + }, + ) + + tmp_dolphin_next = tmp_dir.joinpath("dolphin_next") + print(f"install next to {tmp_dolphin_next}") + # XXX copies from just-created dir so Dolphin.ini is kept + shutil.copytree(tmp_dolphin, tmp_dolphin_next) + tmp_dolphin_next.joinpath("updater-test-file").write_text("test") + with tmp_dolphin_next.joinpath("build_info.txt").open("a") as f: + print("test", file=f) + for ext in ("exe", "dll"): + for path in tmp_dolphin_next.glob("**/*." + ext): + data = bytearray(path.read_bytes()) + richpos = data[:0x200].find(b"Rich") + if richpos < 0: + continue + data[richpos : richpos + 4] = b"DOLP" + path.write_bytes(data) + + HTTPRequestHandler.current = Manifest(tmp_dolphin) + HTTPRequestHandler.next = Manifest(tmp_dolphin_next) + HTTPRequestHandler.done = threading.Event() + + tmp_env = os.environ + tmp_env.update({"DOLPHIN_UPDATE_SERVER_URL": DOLPHIN_UPDATE_SERVER_URL}) + tmp_dolphin_bin = tmp_dolphin.joinpath(dolphin_bin_path.name) + result = subprocess.run(tmp_dolphin_bin, env=tmp_env) + assert result.returncode == 0 + + assert HTTPRequestHandler.done.wait(60 * 2) + # works fine but raises exceptions... + try: + os.kill(HTTPRequestHandler.dolphin_pid, 0) + except: + pass + try: + os.waitpid(HTTPRequestHandler.dolphin_pid, 0) + except: + pass + + failed = False + for path in tmp_dolphin_next.glob("**/*.*"): + if not path.is_file(): + continue + path_rel = path.relative_to(tmp_dolphin_next) + if path_rel.parts[0] == "User": + continue + new_path = tmp_dolphin.joinpath(path_rel) + if not new_path.exists(): + print(f"missing: {new_path}") + failed = True + continue + if ( + sha256(new_path.read_bytes()).digest() + != sha256(path.read_bytes()).digest() + ): + print(f"bad digest: {new_path} {path}") + failed = True + continue + assert not failed + + print(tmp_dolphin.joinpath("User/Logs/Updater.log").read_text()) + # while True: time.sleep(1)