add self updater (#1352)

* add self updater

* remove updater tests after validation
This commit is contained in:
nmzik
2026-10-10 05:42:32 +02:00
committed by GitHub
parent 679be25c37
commit 307fdcf7ad
12 changed files with 1025 additions and 119 deletions
+15
View File
@@ -0,0 +1,15 @@
# The installer is copied outside the application before it runs. Keep it free of
# Qt and application DLL dependencies so it can replace those files on Windows.
if(MSVC)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
endif()
add_executable(kyty_updater main.cpp UpdateInstaller.cpp)
if(MSVC)
target_compile_options(kyty_updater PRIVATE /EHsc)
else()
target_compile_options(kyty_updater PRIVATE -fexceptions)
endif()
if(MINGW)
target_link_options(kyty_updater PRIVATE -static -municode)
endif()
+249
View File
@@ -0,0 +1,249 @@
#include "UpdateInstaller.h"
#include <algorithm>
#include <stdexcept>
#include <vector>
namespace Kyty::Updater {
namespace {
namespace fs = std::filesystem;
struct Entry {
fs::path relative;
fs::file_type type;
};
struct Plan {
fs::path source;
fs::path target;
std::vector<Entry> entries;
};
bool IsWithin(const fs::path& path, const fs::path& root) {
auto part = path.begin();
for (const auto& component: root) {
if (part == path.end() || *part++ != component) {
return false;
}
}
return true;
}
bool IsRelative(const fs::path& path) {
if (path.empty() || path.is_absolute() || path.has_root_name()) {
return false;
}
for (const auto& component: path) {
if (component == ".." || component == "." || component.empty()) {
return false;
}
}
return true;
}
fs::file_type Type(const fs::path& path) {
std::error_code error;
const auto status = fs::symlink_status(path, error);
if (error && error != std::errc::no_such_file_or_directory) {
throw fs::filesystem_error("Cannot inspect update path", path, error);
}
return status.type();
}
void CheckLink(const fs::path& path, const fs::path& root) {
const auto link = fs::read_symlink(path);
if (link.is_absolute() || !IsWithin(fs::canonical(path), root)) {
throw std::runtime_error("Update contains a link outside its installation: " +
path.string());
}
}
void CheckDestination(const fs::path& target, const Entry& entry) {
const auto destination = target / entry.relative;
const auto type = Type(destination);
if (type == fs::file_type::not_found) {
return;
}
if ((entry.type == fs::file_type::directory) != (type == fs::file_type::directory) ||
(type != fs::file_type::regular && type != fs::file_type::directory &&
type != fs::file_type::symlink)) {
throw std::runtime_error("Update would replace a different file type: " +
destination.string());
}
if (type == fs::file_type::symlink) {
CheckLink(destination, target);
}
}
Plan MakePlan(const fs::path& source, const fs::path& target, const fs::path& launcher) {
if (!IsRelative(launcher) || Type(source) != fs::file_type::directory ||
Type(target) != fs::file_type::directory) {
throw std::runtime_error(
"Update source, installation directory, or launcher path is invalid.");
}
Plan plan {fs::canonical(source), fs::canonical(target), {}};
if (IsWithin(plan.target, plan.source)) {
throw std::runtime_error("Update source must not contain the installation directory.");
}
// macOS releases also have flat runtime files; install only the signed bundle.
fs::path scope;
if (launcher.begin()->extension() == ".app") {
scope = *launcher.begin();
}
const auto source_root = scope.empty() ? plan.source : plan.source / scope;
if (Type(source_root) != fs::file_type::directory) {
throw std::runtime_error("The release does not contain the application bundle.");
}
const auto binary_directory = launcher.parent_path();
#ifdef _WIN32
const fs::path suffix = ".exe";
#else
const fs::path suffix;
#endif
for (const auto& required: {launcher, binary_directory / ("kyty_emulator" + suffix.string()),
binary_directory / ("kyty_updater" + suffix.string())}) {
if (Type(plan.source / required) != fs::file_type::regular) {
throw std::runtime_error("Release is missing an executable: " + required.string());
}
#ifndef _WIN32
const auto executable =
fs::perms::owner_exec | fs::perms::group_exec | fs::perms::others_exec;
if ((fs::status(plan.source / required).permissions() & executable) == fs::perms::none) {
throw std::runtime_error("Release executable lacks execute permissions: " +
required.string());
}
#endif
}
if (!scope.empty()) {
plan.entries.push_back({scope, fs::file_type::directory});
}
for (const auto& item: fs::recursive_directory_iterator(source_root)) {
const auto relative = item.path().lexically_relative(plan.source);
const auto type = item.symlink_status().type();
if (relative.begin()->string().starts_with(".kyty-update")) {
throw std::runtime_error("Release uses a reserved updater filename.");
}
if (type == fs::file_type::symlink) {
CheckLink(item.path(), source_root);
} else if (type != fs::file_type::directory && type != fs::file_type::regular) {
throw std::runtime_error("Release contains an unsupported file: " + relative.string());
}
plan.entries.push_back({relative, type});
}
// Checking directories before their children prevents destination symlink traversal.
std::sort(plan.entries.begin(), plan.entries.end(),
[](const Entry& left, const Entry& right) { return left.relative < right.relative; });
for (const auto& entry: plan.entries) {
CheckDestination(plan.target, entry);
}
return plan;
}
struct InstallLock {
fs::path path;
void Acquire(const fs::path& target) {
auto candidate = target / ".kyty-update-lock";
if (!fs::create_directory(candidate)) {
throw std::runtime_error(
"Another update is running, or a previous update was interrupted. "
"Close all updater processes, inspect retained backups, then remove " +
candidate.string());
}
path = std::move(candidate);
}
~InstallLock() {
if (!path.empty()) {
std::error_code ignored;
// An incomplete rollback leaves backups here and keeps the lock for recovery.
fs::remove(path, ignored);
}
}
};
struct Change {
fs::path relative;
bool old_moved = false;
bool installed = false;
};
} // namespace
Result Validate(const fs::path& source, const fs::path& target, const fs::path& launcher_relative) {
try {
MakePlan(source, target, launcher_relative);
// Check actual write access, including ACLs, and interrupted/concurrent updates.
InstallLock lock;
lock.Acquire(target);
return {true, {}};
} catch (const std::exception& error) {
return {false, error.what()};
}
}
Result Install(const fs::path& source, const fs::path& target, const fs::path& launcher_relative) {
InstallLock lock;
fs::path backup;
std::vector<Change> changes;
std::vector<fs::path> created_directories;
try {
const auto plan = MakePlan(source, target, launcher_relative);
lock.Acquire(plan.target);
backup = lock.path / "backup";
changes.reserve(plan.entries.size());
created_directories.reserve(plan.entries.size());
// The launcher extracts beside the installation, so files can move directly
// from that staging directory without another full copy of the release.
for (const auto& entry: plan.entries) {
CheckDestination(plan.target, entry);
const auto destination = plan.target / entry.relative;
if (entry.type == fs::file_type::directory) {
if (fs::create_directory(destination)) {
created_directories.push_back(destination);
}
continue;
}
changes.push_back({entry.relative});
auto& change = changes.back();
if (Type(destination) != fs::file_type::not_found) {
fs::create_directories((backup / entry.relative).parent_path());
fs::rename(destination, backup / entry.relative);
change.old_moved = true;
}
fs::rename(plan.source / entry.relative, destination);
change.installed = true;
}
std::error_code cleanup_error;
fs::remove_all(backup, cleanup_error);
return {true, cleanup_error ? "Update installed; backup cleanup failed: " + backup.string()
: std::string {}};
} catch (const std::exception& error) {
std::string message = error.what();
bool restored = true;
for (auto change = changes.rbegin(); change != changes.rend(); ++change) {
try {
const auto destination = target / change->relative;
if (change->installed) {
fs::rename(destination, source / change->relative);
}
if (change->old_moved) {
fs::rename(backup / change->relative, destination);
}
} catch (const std::exception& rollback_error) {
restored = false;
message += "\nRollback failed: " + std::string(rollback_error.what());
}
}
for (auto directory = created_directories.rbegin(); directory != created_directories.rend();
++directory) {
std::error_code ignored;
fs::remove(*directory, ignored);
}
if (!backup.empty()) {
if (restored) {
std::error_code ignored;
fs::remove_all(backup, ignored);
} else {
message += "\nBackup retained at: " + backup.string();
}
}
return {false, message};
}
}
} // namespace Kyty::Updater
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <filesystem>
#include <string>
namespace Kyty::Updater {
struct Result {
bool success;
std::string message;
};
// Only release files are replaced. Files absent from the release are preserved.
// Successful installation consumes staged files; stage on the target filesystem.
Result Validate(const std::filesystem::path& source, const std::filesystem::path& target,
const std::filesystem::path& launcher_relative);
Result Install(const std::filesystem::path& source, const std::filesystem::path& target,
const std::filesystem::path& launcher_relative);
} // namespace Kyty::Updater
+167
View File
@@ -0,0 +1,167 @@
#include "UpdateInstaller.h"
#include <chrono>
#include <fstream>
#include <iostream>
#include <limits>
#include <stdexcept>
#include <thread>
#include <vector>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#else
#include <cerrno>
#include <csignal>
#include <spawn.h>
#include <unistd.h>
extern char** environ;
#endif
namespace {
namespace fs = std::filesystem;
bool WaitForParent(unsigned long pid) {
#ifdef _WIN32
HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, pid);
if (!process) {
return GetLastError() == ERROR_INVALID_PARAMETER;
}
const auto result = WaitForSingleObject(process, 120000);
CloseHandle(process);
return result == WAIT_OBJECT_0;
#else
if (pid > static_cast<unsigned long>(std::numeric_limits<pid_t>::max())) {
return false;
}
const auto deadline = std::chrono::steady_clock::now() + std::chrono::minutes(2);
while (std::chrono::steady_clock::now() < deadline) {
if (kill(static_cast<pid_t>(pid), 0) != 0) {
return errno == ESRCH;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return false;
#endif
}
bool Launch(const fs::path& launcher, const fs::path& directory) {
#ifdef _WIN32
std::wstring command = L"\"" + launcher.wstring() + L"\"";
STARTUPINFOW startup {};
startup.cb = sizeof(startup);
PROCESS_INFORMATION process {};
if (!CreateProcessW(launcher.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
directory.c_str(), &startup, &process)) {
return false;
}
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
return true;
#else
if (chdir(directory.c_str()) != 0) {
return false;
}
setsid();
pid_t child;
char* arguments[] = {const_cast<char*>(launcher.c_str()), nullptr};
return posix_spawn(&child, launcher.c_str(), nullptr, nullptr, arguments, environ) == 0;
#endif
}
void WriteResult(const fs::path& workspace, const fs::path& target,
const Kyty::Updater::Result& result) try {
const auto temporary = workspace / "update-result.txt";
std::ofstream stream(temporary, std::ios::trunc);
stream << (result.success ? "success\n" : "failure\n") << result.message;
stream.close();
if (!stream) {
throw std::runtime_error("Cannot write the update result.");
}
const auto destination = target / ".kyty-update-result";
// Never open the destination: rename safely replaces even an existing symlink.
std::error_code ignored;
fs::remove(destination, ignored);
fs::rename(temporary, destination);
} catch (const std::exception& error) {
// Reporting must never prevent the original or updated launcher restarting.
std::cerr << "Cannot record update result: " << error.what() << '\n';
}
void CleanupWorkspace(const fs::path& source, const fs::path& target) try {
const auto workspace = source.parent_path();
if (source.filename() != "payload" ||
!workspace.filename().string().starts_with(".kyty-update-") ||
fs::canonical(workspace.parent_path()) != fs::canonical(target)) {
return;
}
// Windows cannot unlink the running helper. Remove the archive and payload
// separately so that only this small executable can remain after success.
for (const auto& entry: fs::directory_iterator(workspace)) {
std::error_code ignored;
fs::remove_all(entry.path(), ignored);
}
std::error_code ignored;
fs::remove(workspace, ignored);
} catch (const std::exception& error) {
std::cerr << "Update installed; temporary files could not be removed: " << error.what() << '\n';
}
int Run(const std::vector<fs::path>& arguments) {
const bool validate = arguments.size() == 5 && arguments[1] == "--validate";
const bool install = arguments.size() == 7 && arguments[1] == "--install";
if (!validate && !install) {
std::cerr
<< "Usage: kyty_updater --validate SOURCE TARGET LAUNCHER_REL\n"
" kyty_updater --install SOURCE TARGET LAUNCHER_REL PARENT_PID WORKDIR\n";
return 2;
}
const auto source = fs::absolute(arguments[2]);
const auto target = fs::absolute(arguments[3]);
const auto launcher = arguments[4];
if (validate) {
const auto result = Kyty::Updater::Validate(source, target, launcher);
if (!result.success) {
std::cerr << result.message << '\n';
}
return result.success ? 0 : 1;
}
const auto pid_string = arguments[5].string();
std::size_t consumed = 0;
const auto pid = std::stoul(pid_string, &consumed);
if (pid <= 1 || consumed != pid_string.size() || pid_string.front() == '-') {
throw std::runtime_error("Invalid launcher process identifier.");
}
if (!WaitForParent(pid)) {
WriteResult(source.parent_path(), target,
{false, "Update cancelled: the launcher did not exit within two minutes."});
return 1;
}
auto result = Kyty::Updater::Install(source, target, launcher);
WriteResult(source.parent_path(), target, result);
if (!Launch(target / launcher, fs::absolute(arguments[6]))) {
result.message += "\nThe launcher could not restart. Open it manually.";
WriteResult(source.parent_path(), target, result);
std::cerr << result.message << '\n';
return 1;
}
if (result.success) {
CleanupWorkspace(source, target);
}
return result.success ? 0 : 1;
}
} // namespace
#ifdef _WIN32
int wmain(int argc, wchar_t* argv[]) {
#else
int main(int argc, char* argv[]) {
#endif
try {
return Run(std::vector<fs::path>(argv, argv + argc));
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}