diff --git a/CMakeLists.txt b/CMakeLists.txt index a0e5759..20984a3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,8 @@ target_link_libraries(MultiPadTester PRIVATE dxguid.lib setupapi.lib runtimeobject.lib + winhttp.lib + version.lib ) target_compile_definitions(MultiPadTester PRIVATE diff --git a/src/main.cpp b/src/main.cpp index e949ce6..6767dcb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include #include @@ -30,6 +32,7 @@ #include "hidhide_probe.h" #include "libwdi_probe.h" #include "resource.h" +#include "update_check.h" #define IDM_ABOUT 0xF200 #define IDM_PREFERENCES 0xF210 @@ -43,6 +46,8 @@ struct AppPrefs int windowW = 0; // 0 = use default position/size int windowH = 0; int lastTabIndex = 0; // backend tab index to restore on launch + /** UTC Unix seconds when the user dismissed the update dialog; 0 = never. Suppresses checks for 24h. */ + int64_t updateDismissedUnix = 0; }; /** @@ -69,7 +74,7 @@ static std::wstring GetConfigPath() * * Reads the config file located in the application's AppData folder and applies recognized keys * from the [Settings] section into the provided AppPrefs structure. Supported keys: - * RefreshRate, VSync, WindowX, WindowY, WindowW, WindowH, LastTabIndex. + * RefreshRate, VSync, WindowX, WindowY, WindowW, WindowH, LastTabIndex, UpdateDismissedUnix. * * - If the config file is missing or unreadable, the function leaves prefs unchanged. * - RefreshRate is accepted only if it equals 0, 60, 75, 120, or 144; other values are ignored. @@ -143,6 +148,10 @@ static void LoadConfig(AppPrefs& prefs) { try { prefs.lastTabIndex = std::stoi(val); } catch (...) {} } + else if (key == "UpdateDismissedUnix") + { + try { prefs.updateDismissedUnix = std::stoll(val); } catch (...) {} + } } // Treat invalid dimensions as "not set" if (prefs.windowW <= 0 || prefs.windowH <= 0) @@ -160,6 +169,7 @@ static void LoadConfig(AppPrefs& prefs) * - vsync: vertical sync enabled flag * - windowX, windowY, windowW, windowH: saved window position and size * - lastTabIndex: backend tab index to restore on launch + * - updateDismissedUnix: UTC Unix time when update dialog was dismissed */ static void SaveConfig(const AppPrefs& prefs) { @@ -177,6 +187,7 @@ static void SaveConfig(const AppPrefs& prefs) f << "WindowW=" << prefs.windowW << "\n"; f << "WindowH=" << prefs.windowH << "\n"; f << "LastTabIndex=" << prefs.lastTabIndex << "\n"; + f << "UpdateDismissedUnix=" << prefs.updateDismissedUnix << "\n"; } /** @@ -231,6 +242,11 @@ static std::vector g_libwdiUsbInstanceIdsUtf8; static std::string g_libwdiUsbProbeErrorUtf8; static AppPrefs g_prefs; +static std::unique_ptr g_updateCheckSession; +static bool g_showUpdateAvailable = false; +static std::string g_updateLocalVerUtf8; +static std::string g_updateRemoteVerUtf8; + static std::string WideToUtf8(const std::wstring_view w) { if (w.empty()) @@ -275,6 +291,18 @@ static LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) switch (msg) { + case WM_UPDATE_CHECK_READY: + { + std::string loc; + std::string rem; + if (UpdateCheck_PopResultForUi(loc, rem)) + { + g_updateLocalVerUtf8 = std::move(loc); + g_updateRemoteVerUtf8 = std::move(rem); + g_showUpdateAvailable = true; + } + } + return 0; case WM_SIZE: if (g_d3d.device && wParam != SIZE_MINIMIZED) g_d3d.Resize(lParam); @@ -294,6 +322,7 @@ static LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) return 0; break; case WM_DESTROY: + g_updateCheckSession.reset(); { RECT r; if (GetWindowRect(hWnd, &r)) @@ -458,6 +487,14 @@ int APIENTRY wWinMain( ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); + try + { + g_updateCheckSession = std::make_unique(hwnd, g_prefs.updateDismissedUnix); + } + catch (...) + { + } + constexpr float clearColor[4] = {0.06f, 0.06f, 0.07f, 1.0f}; MSG msg{}; @@ -694,6 +731,51 @@ int APIENTRY wWinMain( ImGui::EndPopup(); } + const char* const kUpdateAvailablePopupId = "Update available"; + if (g_showUpdateAvailable) + ImGui::OpenPopup(kUpdateAvailablePopupId); + + const bool updatePopupActive = + g_showUpdateAvailable || ImGui::IsPopupOpen(kUpdateAvailablePopupId, ImGuiPopupFlags_None); + if (updatePopupActive) + { + const float updateMinW = 400.f, updateMinH = 140.f; + ImGui::SetNextWindowSizeConstraints(ImVec2(updateMinW, updateMinH), + ImVec2(FLT_MAX, FLT_MAX)); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), + ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + } + if (ImGui::BeginPopupModal( + kUpdateAvailablePopupId, + &g_showUpdateAvailable, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoResize)) + { + ImGui::TextWrapped("A newer version of MultiPad Tester is available."); + ImGui::Spacing(); + ImGui::Text("Installed version: %s", g_updateLocalVerUtf8.c_str()); + ImGui::Text("Latest version: %s", g_updateRemoteVerUtf8.c_str()); + ImGui::Spacing(); + if (ImGui::Button("Download update", ImVec2(140, 0))) + { + ShellExecuteW( + nullptr, + L"open", + UpdateCheck_GetLatestDownloadUrlW(), + nullptr, + nullptr, + SW_SHOWNORMAL); + } + ImGui::SameLine(); + if (ImGui::Button("Not today", ImVec2(130, 0))) + { + g_prefs.updateDismissedUnix = static_cast(std::time(nullptr)); + SaveConfig(g_prefs); + ImGui::CloseCurrentPopup(); + g_showUpdateAvailable = false; + } + ImGui::EndPopup(); + } + const char* const kHidHideActivePopupId = "HidHide Active Warning"; if (g_showHidHideWarning) ImGui::OpenPopup(kHidHideActivePopupId); @@ -834,6 +916,8 @@ int APIENTRY wWinMain( g_d3d.Present(g_prefs.vsync); } + g_updateCheckSession.reset(); + g_backends = nullptr; ImGui_ImplDX11_Shutdown(); diff --git a/src/update_check.cpp b/src/update_check.cpp new file mode 100644 index 0000000..daa741d --- /dev/null +++ b/src/update_check.cpp @@ -0,0 +1,383 @@ +#include "update_check.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr wchar_t kDownloadUrl[] = + L"https://buildbot.nefarius.at/builds/MultiPadTester/latest/MultiPadTester.zip"; + +namespace +{ +constexpr wchar_t kJsonHost[] = L"buildbot.nefarius.at"; +constexpr wchar_t kJsonPath[] = + L"/builds/MultiPadTester/latest/.MultiPadTester/.MultiPadTester.exe.json"; +constexpr DWORD kResolveMs = 10'000; +constexpr DWORD kConnectMs = 10'000; +constexpr DWORD kSendMs = 30'000; +constexpr DWORD kReceiveMs = 30'000; + +constexpr int64_t kSuppressSeconds = 24 * 60 * 60; + +struct VersionQuad +{ + uint16_t a = 0, b = 0, c = 0, d = 0; +}; + +std::mutex g_resultMutex; +std::string g_pendingLocal; +std::string g_pendingRemote; +bool g_havePending = false; + +void ClearPendingResult() +{ + std::lock_guard lock(g_resultMutex); + g_havePending = false; + g_pendingLocal.clear(); + g_pendingRemote.clear(); +} + +bool ParseVersionString(std::string_view s, VersionQuad& out) +{ + out = {}; + int part = 0; + uint32_t cur = 0; + for (char ch : s) + { + if (ch == '.') + { + if (part >= 4) + return false; + if (part == 0) + out.a = static_cast(cur); + else if (part == 1) + out.b = static_cast(cur); + else if (part == 2) + out.c = static_cast(cur); + ++part; + cur = 0; + continue; + } + if (ch < '0' || ch > '9') + return false; + cur = cur * 10u + static_cast(ch - '0'); + if (cur > 65535u) + return false; + } + if (part == 0) + out.a = static_cast(cur); + else if (part == 1) + out.b = static_cast(cur); + else if (part == 2) + out.c = static_cast(cur); + else if (part == 3) + out.d = static_cast(cur); + else + return false; + return true; +} + +int CompareVersionQuad(const VersionQuad& l, const VersionQuad& r) +{ + if (l.a != r.a) + return (l.a < r.a) ? -1 : 1; + if (l.b != r.b) + return (l.b < r.b) ? -1 : 1; + if (l.c != r.c) + return (l.c < r.c) ? -1 : 1; + if (l.d != r.d) + return (l.d < r.d) ? -1 : 1; + return 0; +} + +bool GetLocalExeVersion(VersionQuad& quad, std::string& displayUtf8) +{ + wchar_t path[MAX_PATH]{}; + if (GetModuleFileNameW(nullptr, path, MAX_PATH) == 0) + return false; + DWORD handle = 0; + const DWORD verSize = GetFileVersionInfoSizeW(path, &handle); + if (verSize == 0) + return false; + std::vector buf(verSize); + if (!GetFileVersionInfoW(path, 0, verSize, buf.data())) + return false; + VS_FIXEDFILEINFO* ffi = nullptr; + UINT ffiLen = 0; + if (!VerQueryValueW(buf.data(), L"\\", reinterpret_cast(&ffi), &ffiLen) || !ffi || + ffiLen < sizeof(VS_FIXEDFILEINFO)) + return false; + const uint32_t ms = ffi->dwFileVersionMS; + const uint32_t ls = ffi->dwFileVersionLS; + quad.a = HIWORD(ms); + quad.b = LOWORD(ms); + quad.c = HIWORD(ls); + quad.d = LOWORD(ls); + displayUtf8 = std::format("{}.{}.{}.{}", quad.a, quad.b, quad.c, quad.d); + return true; +} + +bool ExtractJsonFileVersion(std::string_view json, std::string& outVer) +{ + outVer.clear(); + const std::string_view key = "\"FileVersion\""; + const size_t pos = json.find(key); + if (pos == std::string_view::npos) + return false; + size_t i = pos + key.size(); + while (i < json.size() && (json[i] == ' ' || json[i] == '\t' || json[i] == '\r' || json[i] == '\n')) + ++i; + if (i >= json.size() || json[i] != ':') + return false; + ++i; + while (i < json.size() && (json[i] == ' ' || json[i] == '\t' || json[i] == '\r' || json[i] == '\n')) + ++i; + if (i >= json.size() || json[i] != '"') + return false; + ++i; + const size_t start = i; + while (i < json.size() && json[i] != '"') + ++i; + if (i >= json.size()) + return false; + outVer.assign(json.data() + start, i - start); + return !outVer.empty(); +} + +bool HttpGetUtf8(std::stop_token st, const wchar_t* host, INTERNET_PORT port, const wchar_t* path, std::string& bodyOut) +{ + bodyOut.clear(); + if (st.stop_requested()) + return false; + HINTERNET hSession = WinHttpOpen( + L"MultiPadTester/1.0", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + if (!hSession) + return false; + auto closeSession = [hSession]() { WinHttpCloseHandle(hSession); }; + (void)WinHttpSetTimeouts(hSession, static_cast(kResolveMs), static_cast(kConnectMs), + static_cast(kSendMs), static_cast(kReceiveMs)); + + HINTERNET hConnect = WinHttpConnect(hSession, host, port, 0); + if (!hConnect) + { + closeSession(); + return false; + } + HINTERNET hRequest = WinHttpOpenRequest( + hConnect, + L"GET", + path, + nullptr, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + WINHTTP_FLAG_SECURE); + if (!hRequest) + { + WinHttpCloseHandle(hConnect); + closeSession(); + return false; + } + + const BOOL sent = WinHttpSendRequest( + hRequest, + WINHTTP_NO_ADDITIONAL_HEADERS, + 0, + WINHTTP_NO_REQUEST_DATA, + 0, + 0, + 0); + if (!sent) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + closeSession(); + return false; + } + if (!WinHttpReceiveResponse(hRequest, nullptr)) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + closeSession(); + return false; + } + + DWORD status = 0; + DWORD statusSize = sizeof(status); + if (!WinHttpQueryHeaders( + hRequest, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, + &status, + &statusSize, + WINHTTP_NO_HEADER_INDEX) || + status != 200) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + closeSession(); + return false; + } + + for (;;) + { + if (st.stop_requested()) + { + bodyOut.clear(); + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + closeSession(); + return false; + } + char buf[8192]; + DWORD read = 0; + if (!WinHttpReadData(hRequest, buf, sizeof(buf), &read)) + { + bodyOut.clear(); + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + closeSession(); + return false; + } + if (read == 0) + break; + bodyOut.append(buf, read); + } + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + closeSession(); + return !bodyOut.empty(); +} + +void RunUpdateCheck( + std::stop_token st, + std::atomic& hwndSlot, + HWND expectedHwnd, + int64_t dismissedUnix) +{ + if (st.stop_requested()) + return; + + const std::time_t now = std::time(nullptr); + if (dismissedUnix > 0) + { + const int64_t elapsed = static_cast(now) - dismissedUnix; + if (elapsed >= 0 && elapsed < kSuppressSeconds) + return; + } + + if (st.stop_requested()) + return; + + std::string json; + if (!HttpGetUtf8(st, kJsonHost, INTERNET_DEFAULT_HTTPS_PORT, kJsonPath, json)) + return; + + if (st.stop_requested()) + return; + + std::string remoteVerStr; + if (!ExtractJsonFileVersion(json, remoteVerStr)) + return; + + VersionQuad remote{}; + if (!ParseVersionString(remoteVerStr, remote)) + return; + + VersionQuad local{}; + std::string localDisplay; + if (!GetLocalExeVersion(local, localDisplay)) + return; + + if (CompareVersionQuad(remote, local) <= 0) + return; + + if (st.stop_requested()) + return; + + const HWND slotHwnd = hwndSlot.load(std::memory_order_acquire); + if (slotHwnd != expectedHwnd || !IsWindow(expectedHwnd)) + return; + + if (st.stop_requested()) + return; + + { + std::lock_guard lock(g_resultMutex); + g_pendingLocal = std::move(localDisplay); + g_pendingRemote = std::move(remoteVerStr); + g_havePending = true; + } + + if (st.stop_requested()) + { + ClearPendingResult(); + return; + } + + const HWND slot2 = hwndSlot.load(std::memory_order_acquire); + if (slot2 != expectedHwnd || !IsWindow(expectedHwnd)) + { + ClearPendingResult(); + return; + } + + if (st.stop_requested()) + { + ClearPendingResult(); + return; + } + + PostMessageW(expectedHwnd, WM_UPDATE_CHECK_READY, 0, 0); +} +} // namespace + +const wchar_t* UpdateCheck_GetLatestDownloadUrlW() +{ + return kDownloadUrl; +} + +UpdateCheckSession::UpdateCheckSession(HWND notifyHwnd, int64_t updateDismissedUnix) +{ + hwnd_.store(notifyHwnd, std::memory_order_release); + try + { + worker_.emplace([this, notifyHwnd, updateDismissedUnix](std::stop_token st) { + RunUpdateCheck(st, hwnd_, notifyHwnd, updateDismissedUnix); + }); + } + catch (...) + { + hwnd_.store(nullptr, std::memory_order_release); + } +} + +UpdateCheckSession::~UpdateCheckSession() +{ + hwnd_.store(nullptr, std::memory_order_release); + worker_.reset(); +} + +bool UpdateCheck_PopResultForUi(std::string& localVersionOut, std::string& remoteVersionOut) +{ + std::lock_guard lock(g_resultMutex); + if (!g_havePending) + return false; + localVersionOut = g_pendingLocal; + remoteVersionOut = g_pendingRemote; + g_havePending = false; + g_pendingLocal.clear(); + g_pendingRemote.clear(); + return true; +} diff --git a/src/update_check.h b/src/update_check.h new file mode 100644 index 0000000..c36db84 --- /dev/null +++ b/src/update_check.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +/** Posted to `notifyHwnd` when a newer build is available (worker verified HWND is still current). */ +constexpr UINT WM_UPDATE_CHECK_READY = WM_APP + 42; + +/** + * Owns the background update worker (`std::jthread`): requests stop and joins on destruction so shutdown + * does not race `PostMessage` or namespace-scope result state. The constructor swallows thread start failures. + */ +class UpdateCheckSession +{ +public: + explicit UpdateCheckSession(HWND notifyHwnd, int64_t updateDismissedUnix); + ~UpdateCheckSession(); + + UpdateCheckSession(const UpdateCheckSession&) = delete; + UpdateCheckSession& operator=(const UpdateCheckSession&) = delete; + UpdateCheckSession(UpdateCheckSession&&) = delete; + UpdateCheckSession& operator=(UpdateCheckSession&&) = delete; + +private: + std::atomic hwnd_{nullptr}; + std::optional worker_; +}; + +/** Call from the main thread when handling WM_UPDATE_CHECK_READY; copies version strings for the UI. */ +bool UpdateCheck_PopResultForUi(std::string& localVersionOut, std::string& remoteVersionOut); + +/** HTTPS URL of the latest portable ZIP (build artifact); single source of truth with the update checker. */ +const wchar_t* UpdateCheck_GetLatestDownloadUrlW();