diff --git a/.github/workflows/windows_release.yml b/.github/workflows/windows_release.yml index b35c397..eff731a 100644 --- a/.github/workflows/windows_release.yml +++ b/.github/workflows/windows_release.yml @@ -105,6 +105,7 @@ jobs: shell: pwsh env: ZIP_NAME: ${{ steps.ver.outputs.zip_name }} + ASSET_VER: ${{ steps.ver.outputs.asset_ver }} run: | $ErrorActionPreference = "Stop" $exe = Get-ChildItem -Path . -Recurse -Filter infinipaint.exe | @@ -115,6 +116,8 @@ jobs: Remove-Item -Recurse -Force $outDir -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $outDir | Out-Null Copy-Item $exe.FullName (Join-Path $outDir "infinipaint.exe") + # Identity file for humans / debugging (app also embeds SHA at compile time). + Set-Content -Path (Join-Path $outDir "version.txt") -Value $env:ASSET_VER -Encoding ascii $dllDir = Join-Path (Get-Location) "windowsinstall\dlls-x86_64" if (Test-Path $dllDir) { Copy-Item (Join-Path $dllDir "*.dll") $outDir -ErrorAction SilentlyContinue diff --git a/CMakeLists.txt b/CMakeLists.txt index 691cd56..d01183f 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,7 @@ set(sources "src/main.cpp" "src/WorldScreenshot.cpp" "src/FileHelpers.cpp" "src/VersionConstants.cpp" + "src/WindowsPortableUpdater.cpp" "src/ScaleUpCanvas.cpp" "src/WorldUndoManager.cpp" "src/GUIHolder.cpp" @@ -200,6 +201,30 @@ else() add_executable(main ${sources}) endif() +# Short git SHA for continuous-release identity (Windows auto-update, About). +set(INFINIPAINT_GIT_SHA "unknown") +find_package(Git QUIET) +if(GIT_FOUND) + execute_process( + COMMAND "${GIT_EXECUTABLE}" rev-parse --short=7 HEAD + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + OUTPUT_VARIABLE INFINIPAINT_GIT_SHA + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) +endif() +if(NOT INFINIPAINT_GIT_SHA) + set(INFINIPAINT_GIT_SHA "unknown") +endif() +file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/generated") +configure_file( + "${CMAKE_SOURCE_DIR}/src/BuildCommit.hpp.in" + "${CMAKE_BINARY_DIR}/generated/BuildCommit.hpp" + @ONLY +) +target_include_directories(main PRIVATE "${CMAKE_BINARY_DIR}/generated") +message(STATUS "InfiniPaint build commit: ${INFINIPAINT_GIT_SHA}") + # Get libraries if(NO_CONAN_BUILD) @@ -382,7 +407,8 @@ else() endif() if((NOT EMSCRIPTEN) AND WIN32) - target_link_libraries(main dwrite) + target_link_libraries(main dwrite shell32) + # shell32: ShellExecuteA for portable auto-update helper script # Clip library for retrieving images from windows clipboard set(CLIP_EXAMPLES OFF CACHE BOOL "Compile clip examples") set(CLIP_TESTS OFF CACHE BOOL "Compile clip tests") diff --git a/FORK.md b/FORK.md index 4e07232..9010ebb 100644 --- a/FORK.md +++ b/FORK.md @@ -14,6 +14,7 @@ Save format: `INFPNT000009` / version `0.9.0-fork` (not network-compatible with | `.github/workflows/windows_release.yml` | private GitHub mirror | GitHub-hosted **`windows-latest`** | compiles zip + uploads public Forgejo `windows-continuous` | **Friends (Windows):** https://forgejo.fsociety00.cc/copilot/infinipaint/releases/tag/windows-continuous — zip entpacken, `infinipaint.exe` starten. +Portable Windows builds check that Forgejo tag on startup (if “Check for updates” is on) and can **download + install** the newer zip in place. Details: [docs/windows-runner.md](docs/windows-runner.md). ## Bring Everyone Here (host) diff --git a/docs/windows-runner.md b/docs/windows-runner.md index 1b8c97f..fde81cd 100644 --- a/docs/windows-runner.md +++ b/docs/windows-runner.md @@ -17,6 +17,14 @@ https://forgejo.fsociety00.cc/copilot/infinipaint/releases/tag/windows-continuou Portable zip → extract → run `infinipaint.exe`. +### Auto-update (portable) + +Windows builds embed the short git SHA at configure time. On startup they poll the +public Forgejo API for tag `windows-continuous`. If the release commit differs, +a dialog offers **Download and install update** (downloads the zip, extracts over +the install folder via a helper script after quit, then restarts). Toggle via +Settings → check for updates. + ## Workflows | File | Runner | Role | diff --git a/src/BuildCommit.hpp.in b/src/BuildCommit.hpp.in new file mode 100644 index 0000000..7ae8228 --- /dev/null +++ b/src/BuildCommit.hpp.in @@ -0,0 +1,3 @@ +#pragma once +// Generated at configure time from git HEAD (short sha). +#define INFINIPAINT_BUILD_COMMIT "@INFINIPAINT_GIT_SHA@" diff --git a/src/GlobalConfig.cpp b/src/GlobalConfig.cpp index a6efc1a..a170616 100644 --- a/src/GlobalConfig.cpp +++ b/src/GlobalConfig.cpp @@ -251,6 +251,6 @@ void GlobalConfig::load_licenses() { std::sort(thirdPartyLicenses.begin(), thirdPartyLicenses.end(), [](const auto& a1, const auto& a2) { return std::lexicographical_compare(a1.first.begin(), a1.first.end(), a2.first.begin(), a2.first.end()); }); - ownLicenseText = "InfiniPaint v" + VersionConstants::CURRENT_VERSION_STRING + "\n"; + ownLicenseText = "InfiniPaint v" + VersionConstants::full_version_string() + "\n"; ownLicenseText += read_file_to_string("data/license"); } diff --git a/src/MainProgram.cpp b/src/MainProgram.cpp index 0c4f4da..ab3d9e6 100755 --- a/src/MainProgram.cpp +++ b/src/MainProgram.cpp @@ -51,6 +51,8 @@ #endif #include +#include "VersionConstants.hpp" +#include "WindowsPortableUpdater.hpp" #ifdef __ANDROID__ #define UPDATE_NOTIFICATION_URL "https://infinipaint.com/updateNotificationVersionAndroid.txt" @@ -121,18 +123,95 @@ void MainProgram::update() { post_callback(); } +void MainProgram::start_windows_portable_update_download() { +#ifdef _WIN32 + if(updateCheckerData.downloadingZip || updateCheckerData.downloadUrl.empty()) + return; + updateCheckerData.downloadingZip = true; + updateCheckerData.downloadStatus = "Downloading update…"; + updateCheckerData.zipDownload = FileDownloader::download_data_from_url(updateCheckerData.downloadUrl); + g.gui.set_to_layout(); +#else + (void)0; +#endif +} + void MainProgram::update_notification_check() { #ifndef __EMSCRIPTEN__ +#ifdef _WIN32 + // Poll in-flight zip download / apply. + if(updateCheckerData.downloadingZip && updateCheckerData.zipDownload) { + switch(updateCheckerData.zipDownload->status) { + case FileDownloader::DownloadData::Status::IN_PROGRESS: { + float p = updateCheckerData.zipDownload->progress.load(); + if(p > 0.f) + updateCheckerData.downloadStatus = "Downloading update… " + std::to_string(int(p * 100.f)) + "%"; + break; + } + case FileDownloader::DownloadData::Status::SUCCESS: { + updateCheckerData.downloadStatus = "Installing update…"; + g.gui.set_to_layout(); + if(!WindowsPortableUpdater::apply_zip_and_restart(updateCheckerData.zipDownload->str)) { + updateCheckerData.downloadStatus = "Update install failed (see log)."; + updateCheckerData.downloadingZip = false; + updateCheckerData.zipDownload = nullptr; + g.gui.set_to_layout(); + } + break; + } + case FileDownloader::DownloadData::Status::FAILURE: + updateCheckerData.downloadStatus = "Download failed."; + updateCheckerData.downloadingZip = false; + updateCheckerData.zipDownload = nullptr; + g.gui.set_to_layout(); + break; + } + } +#endif + if(!updateCheckerData.updateCheckDone) { if(conf.checkForUpdates) { - if(!updateCheckerData.versionFile) + if(!updateCheckerData.versionFile) { +#ifdef _WIN32 + // Fork continuous builds: compare git SHA against Forgejo windows-continuous. + updateCheckerData.forgejoContinuous = true; + updateCheckerData.versionFile = FileDownloader::download_data_from_url(FORGEJO_WINDOWS_CONTINUOUS_API); +#else updateCheckerData.versionFile = FileDownloader::download_data_from_url(UPDATE_NOTIFICATION_URL); +#endif + } else { switch(updateCheckerData.versionFile->status) { case FileDownloader::DownloadData::Status::IN_PROGRESS: break; case FileDownloader::DownloadData::Status::SUCCESS: { updateCheckerData.updateCheckDone = true; +#ifdef _WIN32 + std::string label, commit, zipUrl; + if(WindowsPortableUpdater::parse_continuous_release_json( + updateCheckerData.versionFile->str, label, commit, zipUrl)) { + updateCheckerData.remoteCommit = commit; + updateCheckerData.downloadUrl = zipUrl; + updateCheckerData.newVersionStr = label.empty() + ? (VersionConstants::CURRENT_VERSION_STRING + "+" + commit) + : label; + const std::string& local = VersionConstants::build_commit_short(); + Logger::get().log(Logger::LogType::INFO, + "Forgejo continuous commit=" + commit + " local=" + local); + if(local != "unknown" && commit != local.substr(0, commit.size())) { + updateCheckerData.showGui = true; + Logger::get().log(Logger::LogType::PHONE_USERINFO, + "Windows update available: " + updateCheckerData.newVersionStr); + g.gui.set_to_layout(); + } + else if(local == "unknown") + Logger::get().log(Logger::LogType::INFO, "Local build commit unknown — skip update prompt"); + else + Logger::get().log(Logger::LogType::INFO, "Windows portable build is up to date"); + } + else + Logger::get().log(Logger::LogType::INFO, "Could not parse Forgejo windows-continuous release"); +#else std::optional newVersion = version_str_to_version_numbers(updateCheckerData.versionFile->str); std::optional currentVersion = VersionConstants::CURRENT_VERSION_NUMBER; if(newVersion.has_value() && currentVersion.has_value()) { @@ -152,6 +231,7 @@ void MainProgram::update_notification_check() { } else Logger::get().log(Logger::LogType::INFO, "Update notification file couldn't be converted to version numbers"); +#endif updateCheckerData.versionFile = nullptr; break; } diff --git a/src/MainProgram.hpp b/src/MainProgram.hpp index ee8c62e..d855884 100644 --- a/src/MainProgram.hpp +++ b/src/MainProgram.hpp @@ -64,6 +64,11 @@ class MainProgram { public: static constexpr size_t LOG_SIZE = 30; static constexpr const char* UPDATE_DOWNLOAD_URL = "https://infinipaint.com/download.html"; + /** Public Forgejo continuous Windows release (fork). */ + static constexpr const char* FORGEJO_WINDOWS_CONTINUOUS_API = + "https://forgejo.fsociety00.cc/api/v1/repos/copilot/infinipaint/releases/tags/windows-continuous"; + static constexpr const char* FORGEJO_WINDOWS_CONTINUOUS_PAGE = + "https://forgejo.fsociety00.cc/copilot/infinipaint/releases/tag/windows-continuous"; InputManager input; @@ -198,11 +203,18 @@ class MainProgram { ~MainProgram(); void update_notification_check(); + void start_windows_portable_update_download(); struct UpdateCheckerData { bool showGui = false; bool updateCheckDone = false; std::string newVersionStr; + std::string downloadUrl; + std::string remoteCommit; + bool forgejoContinuous = false; + bool downloadingZip = false; + std::string downloadStatus; std::shared_ptr versionFile; + std::shared_ptr zipDownload; } updateCheckerData; float calculate_gui_scale(); diff --git a/src/Toolbar.cpp b/src/Toolbar.cpp index 145fcdc..3605a26 100644 --- a/src/Toolbar.cpp +++ b/src/Toolbar.cpp @@ -677,27 +677,57 @@ void Toolbar::update_notification_gui() { center_obstructing_window_gui("Update notifications GUI", CLAY_SIZING_FIXED(700), CLAY_SIZING_FIT(0), [&] { gui.new_id("update notification gui", [&] { - text_label_centered(gui, "Update v" + main.updateCheckerData.newVersionStr + " available!"); - text_button(gui, "download", "Open download page in web browser", { - .wide = true, - .onClick = [&]{ - SDL_OpenURL(MainProgram::UPDATE_DOWNLOAD_URL); - main.updateCheckerData.showGui = false; + text_label_centered(gui, "Update available!"); + text_label_centered(gui, main.updateCheckerData.newVersionStr); +#ifdef _WIN32 + if(main.updateCheckerData.forgejoContinuous) { + text_label(gui, "Local: " + VersionConstants::full_version_string()); + if(main.updateCheckerData.downloadingZip) { + text_label_centered(gui, main.updateCheckerData.downloadStatus.empty() + ? "Downloading…" + : main.updateCheckerData.downloadStatus); } - }); - text_button(gui, "ignore forever", "Ignore and don't notify again (can be changed in settings)", { - .wide = true, - .onClick = [&]{ - main.conf.checkForUpdates = false; - main.updateCheckerData.showGui = false; + else { + text_button(gui, "download install", "Download and install update", { + .wide = true, + .onClick = [&]{ + main.start_windows_portable_update_download(); + } + }); + text_button(gui, "open page", "Open Forgejo release page", { + .wide = true, + .onClick = [&]{ + SDL_OpenURL(MainProgram::FORGEJO_WINDOWS_CONTINUOUS_PAGE); + } + }); } - }); - text_button(gui, "ignore for now", "Ignore for now", { - .wide = true, - .onClick = [&]{ - main.updateCheckerData.showGui = false; - } - }); + } + else +#endif + { + text_button(gui, "download", "Open download page in web browser", { + .wide = true, + .onClick = [&]{ + SDL_OpenURL(MainProgram::UPDATE_DOWNLOAD_URL); + main.updateCheckerData.showGui = false; + } + }); + } + if(!main.updateCheckerData.downloadingZip) { + text_button(gui, "ignore forever", "Ignore and don't notify again (can be changed in settings)", { + .wide = true, + .onClick = [&]{ + main.conf.checkForUpdates = false; + main.updateCheckerData.showGui = false; + } + }); + text_button(gui, "ignore for now", "Ignore for now", { + .wide = true, + .onClick = [&]{ + main.updateCheckerData.showGui = false; + } + }); + } }); }); } diff --git a/src/VersionConstants.cpp b/src/VersionConstants.cpp index 1540978..3d41538 100644 --- a/src/VersionConstants.cpp +++ b/src/VersionConstants.cpp @@ -17,10 +17,20 @@ */ #include "VersionConstants.hpp" +#include "BuildCommit.hpp" #include namespace VersionConstants { +const std::string& build_commit_short() { + static const std::string sha = INFINIPAINT_BUILD_COMMIT; + return sha; +} + +std::string full_version_string() { + return CURRENT_VERSION_STRING + "+" + build_commit_short(); +} + VersionNumber header_to_version_number(const std::string& header) { static std::unordered_map m; if(m.empty()) { diff --git a/src/VersionConstants.hpp b/src/VersionConstants.hpp index 4dfb13d..34acb00 100644 --- a/src/VersionConstants.hpp +++ b/src/VersionConstants.hpp @@ -31,4 +31,8 @@ namespace VersionConstants { const std::string CURRENT_SAVEFILE_HEADER = "INFPNT000009"; // Change whenever the save file is incompatible with the previous version const std::string CURRENT_VERSION_STRING = "0.9.0-fork"; constexpr VersionNumber CURRENT_VERSION_NUMBER(0, 9, 0); + /** Short git SHA baked at configure time (`BuildCommit.hpp`). */ + const std::string& build_commit_short(); + /** e.g. `0.9.0-fork+7f34283` */ + std::string full_version_string(); } diff --git a/src/WindowsPortableUpdater.cpp b/src/WindowsPortableUpdater.cpp new file mode 100644 index 0000000..90318a0 --- /dev/null +++ b/src/WindowsPortableUpdater.cpp @@ -0,0 +1,156 @@ +#include "WindowsPortableUpdater.hpp" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#endif + +namespace WindowsPortableUpdater { + +bool parse_continuous_release_json( + const std::string& jsonText, + std::string& outLabel, + std::string& outCommit, + std::string& outZipUrl) +{ + outLabel.clear(); + outCommit.clear(); + outZipUrl.clear(); + try { + auto j = nlohmann::json::parse(jsonText); + outLabel = j.value("name", ""); + std::regex shaRe(R"(\+([0-9a-fA-F]{7,40})\b)"); + std::smatch m; + if(std::regex_search(outLabel, m, shaRe)) + outCommit = m[1].str().substr(0, 7); + if(outCommit.empty()) { + std::string body = j.value("body", ""); + if(std::regex_search(body, m, shaRe)) + outCommit = m[1].str().substr(0, 7); + } + if(!j.contains("assets") || !j["assets"].is_array()) + return false; + // Prefer the zip that matches the release commit (rolling tag keeps older assets). + auto pick_url = [](const nlohmann::json& a) -> std::string { + std::string u = a.value("browser_download_url", ""); + if(u.empty()) + u = a.value("url", ""); + return u; + }; + for(auto& a : j["assets"]) { + std::string name = a.value("name", ""); + if(name.find("win-x86_64-portable.zip") == std::string::npos) + continue; + if(!outCommit.empty() && name.find(outCommit) == std::string::npos) + continue; + if(outCommit.empty() && std::regex_search(name, m, shaRe)) + outCommit = m[1].str().substr(0, 7); + outZipUrl = pick_url(a); + if(!outZipUrl.empty()) + break; + } + // Fallback: newest matching zip by created_at if commit filter found nothing. + if(outZipUrl.empty()) { + std::string bestCreated; + for(auto& a : j["assets"]) { + std::string name = a.value("name", ""); + if(name.find("win-x86_64-portable.zip") == std::string::npos) + continue; + std::string created = a.value("created_at", ""); + if(created < bestCreated && !bestCreated.empty()) + continue; + std::string url = pick_url(a); + if(url.empty()) + continue; + bestCreated = created; + outZipUrl = url; + if(outCommit.empty() && std::regex_search(name, m, shaRe)) + outCommit = m[1].str().substr(0, 7); + } + } + return !outCommit.empty() && !outZipUrl.empty(); + } + catch(const std::exception& e) { + Logger::get().log(Logger::LogType::INFO, std::string("Windows update JSON parse failed: ") + e.what()); + return false; + } +} + +bool apply_zip_and_restart(const std::string& zipBytes) { +#ifdef _WIN32 + namespace fs = std::filesystem; + try { + char* basePath = SDL_GetBasePath(); + if(!basePath) + return false; + fs::path appDir = fs::path(basePath); + SDL_free(basePath); + + fs::path tempRoot = fs::temp_directory_path() / "infinipaint-update"; + fs::create_directories(tempRoot); + fs::path zipPath = tempRoot / "update.zip"; + fs::path extractDir = tempRoot / "extracted"; + fs::path batPath = tempRoot / "apply_update.bat"; + + { + std::ofstream out(zipPath, std::ios::binary); + if(!out) + return false; + out.write(zipBytes.data(), static_cast(zipBytes.size())); + } + + DWORD pid = GetCurrentProcessId(); + std::ofstream bat(batPath); + if(!bat) + return false; + bat << "@echo off\r\n" + << "setlocal\r\n" + << "set \"APPDIR=" << appDir.string() << "\"\r\n" + << "set \"ZIP=" << zipPath.string() << "\"\r\n" + << "set \"OUT=" << extractDir.string() << "\"\r\n" + << "set \"PID=" << pid << "\"\r\n" + << ":wait\r\n" + << "tasklist /FI \"PID eq %PID%\" 2>nul | find \"%PID%\" >nul\r\n" + << "if not errorlevel 1 (\r\n" + << " timeout /t 1 /nobreak >nul\r\n" + << " goto wait\r\n" + << ")\r\n" + << "if exist \"%OUT%\" rmdir /s /q \"%OUT%\"\r\n" + << "mkdir \"%OUT%\" >nul 2>nul\r\n" + << "powershell -NoProfile -ExecutionPolicy Bypass -Command " + << "\"Expand-Archive -Force -Path '%ZIP%' -DestinationPath '%OUT%'\"\r\n" + << "if errorlevel 1 exit /b 1\r\n" + << "xcopy /E /Y /Q \"%OUT%\\*\" \"%APPDIR%\\\" >nul\r\n" + << "start \"\" \"%APPDIR%\\infinipaint.exe\"\r\n" + << "endlocal\r\n"; + bat.close(); + + std::string batStr = batPath.string(); + HINSTANCE hi = ShellExecuteA(nullptr, "open", batStr.c_str(), nullptr, nullptr, SW_HIDE); + if(reinterpret_cast(hi) <= 32) { + Logger::get().log(Logger::LogType::INFO, "ShellExecute apply_update.bat failed"); + return false; + } + Logger::get().log(Logger::LogType::INFO, "Windows update script launched; quitting to apply"); + SDL_Event e{}; + e.type = SDL_EVENT_QUIT; + SDL_PushEvent(&e); + return true; + } + catch(const std::exception& e) { + Logger::get().log(Logger::LogType::INFO, std::string("Windows update apply failed: ") + e.what()); + return false; + } +#else + (void)zipBytes; + return false; +#endif +} + +} diff --git a/src/WindowsPortableUpdater.hpp b/src/WindowsPortableUpdater.hpp new file mode 100644 index 0000000..5464387 --- /dev/null +++ b/src/WindowsPortableUpdater.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +/** Windows portable self-update helpers (Forgejo windows-continuous). */ +namespace WindowsPortableUpdater { + /** Parse Forgejo release JSON; fill remote label, commit, zip URL. Returns true if usable. */ + bool parse_continuous_release_json( + const std::string& jsonText, + std::string& outLabel, + std::string& outCommit, + std::string& outZipUrl); + + /** Write zip + apply script, then request process exit. Returns false on I/O failure. */ + bool apply_zip_and_restart(const std::string& zipBytes); +}