Add Windows portable auto-update from Forgejo continuous releases.
Some checks failed
Windows Release / windows-x86_64 (push) Failing after 1h12m0s

Poll windows-continuous by git SHA, download the matching zip, and apply it in place so friends stay current without manual installs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
copilot 2026-11-12 12:29:49 +01:00
parent 7f34283537
commit cdc4c5cb30
13 changed files with 371 additions and 22 deletions

View file

@ -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

View file

@ -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")

View file

@ -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)

View file

@ -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 |

3
src/BuildCommit.hpp.in Normal file
View file

@ -0,0 +1,3 @@
#pragma once
// Generated at configure time from git HEAD (short sha).
#define INFINIPAINT_BUILD_COMMIT "@INFINIPAINT_GIT_SHA@"

View file

@ -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");
}

View file

@ -51,6 +51,8 @@
#endif
#include <Helpers/Logger.hpp>
#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<VersionNumber> newVersion = version_str_to_version_numbers(updateCheckerData.versionFile->str);
std::optional<VersionNumber> 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;
}

View file

@ -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<FileDownloader::DownloadData> versionFile;
std::shared_ptr<FileDownloader::DownloadData> zipDownload;
} updateCheckerData;
float calculate_gui_scale();

View file

@ -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;
}
});
}
});
});
}

View file

@ -17,10 +17,20 @@
*/
#include "VersionConstants.hpp"
#include "BuildCommit.hpp"
#include <stdexcept>
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<std::string, VersionNumber> m;
if(m.empty()) {

View file

@ -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();
}

View file

@ -0,0 +1,156 @@
#include "WindowsPortableUpdater.hpp"
#include <Helpers/Logger.hpp>
#include <nlohmann/json.hpp>
#include <regex>
#include <fstream>
#include <filesystem>
#ifdef _WIN32
#include <SDL3/SDL.h>
#include <windows.h>
#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<std::streamsize>(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<intptr_t>(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
}
}

View file

@ -0,0 +1,16 @@
#pragma once
#include <string>
/** 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);
}