Compare commits
2 Commits
main
...
add-self-u
| Author | SHA1 | Date |
|---|---|---|
|
|
f1764a0bba | |
|
|
b41c23d338 |
|
|
@ -257,6 +257,7 @@ jobs:
|
|||
"$install_dir/libMoltenVK.dylib" \
|
||||
"$app_dir/Contents/MacOS/KytyPS5" \
|
||||
"$app_dir/Contents/MacOS/kyty_emulator" \
|
||||
"$app_dir/Contents/MacOS/kyty_updater" \
|
||||
"$app_dir/Contents/Frameworks/libMoltenVK.dylib"; do
|
||||
file "$binary"
|
||||
lipo "$binary" -verify_arch x86_64
|
||||
|
|
@ -266,6 +267,7 @@ jobs:
|
|||
"$install_dir/libMoltenVK.dylib" \
|
||||
"$app_dir/Contents/MacOS/KytyPS5" \
|
||||
"$app_dir/Contents/MacOS/kyty_emulator" \
|
||||
"$app_dir/Contents/MacOS/kyty_updater" \
|
||||
"$app_dir/Contents/Frameworks/libMoltenVK.dylib"; do
|
||||
codesign --verify --strict "$binary"
|
||||
done
|
||||
|
|
@ -407,6 +409,7 @@ jobs:
|
|||
run: |
|
||||
file _Build/linux/install/launcher
|
||||
file _Build/linux/install/kyty_emulator
|
||||
test -x _Build/linux/install/kyty_updater
|
||||
file _Build/linux/install/kyty_emulator | grep -q "ELF 64-bit LSB .*x86-64"
|
||||
ldd _Build/linux/install/kyty_emulator > /dev/null
|
||||
readelf -d _Build/linux/install/launcher | grep -q 'RPATH.*\$ORIGIN/lib'
|
||||
|
|
@ -495,7 +498,8 @@ jobs:
|
|||
test -d "$linux_dir"
|
||||
chmod a+x \
|
||||
"$linux_dir/launcher" \
|
||||
"$linux_dir/kyty_emulator"
|
||||
"$linux_dir/kyty_emulator" \
|
||||
"$linux_dir/kyty_updater"
|
||||
tar -C "$linux_dir" -czf \
|
||||
"$GITHUB_WORKSPACE/$RELEASE_NAME-Linux-x86_64.tar.gz" .
|
||||
|
||||
|
|
|
|||
|
|
@ -338,6 +338,14 @@ folders recursively for game directories containing `eboot.bin` and ZArchive (`.
|
|||
whose archive root contains `eboot.bin`. Select a detected game and run it from the game list.
|
||||
ZArchive dumps are mounted read-only and streamed directly; they do not need to be extracted first.
|
||||
|
||||
Official release builds include a built-in updater. Leave **Check for updates on startup** enabled,
|
||||
or click **Check for updates**, then choose **Install Update**. The launcher downloads the package
|
||||
for your platform from GitHub, checks its SHA-256 checksum, and restarts after installation. Close
|
||||
any running games first. Settings, saves, patches, and other files outside the release package are
|
||||
preserved; replacement failures restore the previous files. The installation folder must be writable.
|
||||
If a package cannot be installed automatically, the updater offers the GitHub release page.
|
||||
Source and fork builds do not replace themselves with official releases.
|
||||
|
||||
The emulator can also be started directly with a legally obtained game directory, ELF file, or
|
||||
ZArchive dump:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
find_package(Qt6 COMPONENTS Concurrent Network Widgets REQUIRED)
|
||||
add_subdirectory("${KYTY_SOURCE_DIR}/updater" "${CMAKE_CURRENT_BINARY_DIR}/updater")
|
||||
|
||||
set(CMAKE_AUTOUIC_SEARCH_PATHS forms)
|
||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
|
|
@ -42,7 +43,13 @@ elseif(LINUX)
|
|||
set_target_properties(launcher PROPERTIES LINK_FLAGS "${KYTY_LD_OPTIONS}")
|
||||
endif()
|
||||
|
||||
add_dependencies(launcher kyty_emulator)
|
||||
add_custom_target(stage_updater
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory $<TARGET_FILE_DIR:launcher>
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different $<TARGET_FILE:kyty_updater>
|
||||
$<TARGET_FILE_DIR:launcher>/$<TARGET_FILE_NAME:kyty_updater>
|
||||
DEPENDS kyty_updater
|
||||
VERBATIM)
|
||||
add_dependencies(launcher kyty_emulator stage_updater)
|
||||
|
||||
if(KYTY_CLANG_CL)
|
||||
add_custom_command(TARGET launcher POST_BUILD
|
||||
|
|
@ -96,6 +103,7 @@ install(TARGETS launcher
|
|||
|
||||
if(APPLE)
|
||||
install(TARGETS kyty_emulator DESTINATION "KytyPS5.app/Contents/MacOS")
|
||||
install(TARGETS kyty_updater DESTINATION "KytyPS5.app/Contents/MacOS")
|
||||
install(FILES "${KYTY_TROPHY_UNLOCK_SOUND}" DESTINATION "KytyPS5.app/Contents/Resources/assets/sounds")
|
||||
# Deploy and ad-hoc sign the Qt frameworks and plugins in the bundle.
|
||||
find_program(KYTY_MACDEPLOYQT NAMES macdeployqt PATHS "${Qt6_DIR}/../../../bin" NO_DEFAULT_PATH)
|
||||
|
|
@ -112,6 +120,10 @@ if(APPLE)
|
|||
")
|
||||
endif()
|
||||
|
||||
if(NOT APPLE)
|
||||
install(TARGETS kyty_updater RUNTIME DESTINATION .)
|
||||
endif()
|
||||
|
||||
if(WIN32)
|
||||
install(CODE "
|
||||
execute_process(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
#ifndef UPDATE_CHECKER_H
|
||||
#define UPDATE_CHECKER_H
|
||||
|
||||
#include "updateRelease.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QFile>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <QPointer>
|
||||
#include <QProcess>
|
||||
|
||||
class QByteArray;
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
class QNetworkReply;
|
||||
class QProgressDialog;
|
||||
class QTemporaryDir;
|
||||
class QWidget;
|
||||
|
||||
class UpdateChecker final: public QObject {
|
||||
|
|
@ -14,28 +23,43 @@ class UpdateChecker final: public QObject {
|
|||
|
||||
public:
|
||||
explicit UpdateChecker(QWidget* parent);
|
||||
~UpdateChecker() override;
|
||||
|
||||
[[nodiscard]] static bool IsSupported();
|
||||
void Check(bool manual);
|
||||
static void ShowPreviousResult(QWidget* parent);
|
||||
void Check(bool manual);
|
||||
void SetGameRunning(bool running) { m_game_running = running; }
|
||||
[[nodiscard]] bool IsInstalling() const { return m_installing; }
|
||||
|
||||
signals:
|
||||
void CheckingChanged(bool checking);
|
||||
void InstallingChanged(bool installing);
|
||||
|
||||
private:
|
||||
struct UpdateInfo;
|
||||
void FetchRelease(const QUrl& url, std::function<void(UpdateRelease::Info)> done);
|
||||
void CheckCurrent(const UpdateRelease::Info& current, bool manual);
|
||||
void OfferUpdate();
|
||||
void Download();
|
||||
void ReadDownload(QNetworkReply* reply);
|
||||
void PreparePackage();
|
||||
void RunPreparation(const QString& program, const QStringList& arguments, bool validate);
|
||||
void Restart();
|
||||
void Finish(QString error = {}, bool report = true);
|
||||
|
||||
static UpdateInfo ParseUpdateInfo(const QByteArray& data);
|
||||
void FetchUpdateInfo(const char* url, bool fallback, bool manual);
|
||||
void ShowUpdateResult(const UpdateInfo& info, bool manual);
|
||||
|
||||
QWidget* m_parent = nullptr;
|
||||
QNetworkAccessManager m_network;
|
||||
bool m_checking_updates = false;
|
||||
|
||||
// Answer from the primary update feed, kept while the GitHub fallback feed is
|
||||
// being consulted, so it can still be shown if the fallback request fails.
|
||||
QString m_primary_tag;
|
||||
QUrl m_primary_page_url;
|
||||
QWidget* m_parent;
|
||||
QNetworkAccessManager m_network;
|
||||
QPointer<QNetworkReply> m_reply;
|
||||
QPointer<QProgressDialog> m_progress;
|
||||
QProcess m_prepare;
|
||||
std::unique_ptr<QTemporaryDir> m_workspace;
|
||||
QFile m_download;
|
||||
QCryptographicHash m_hash {QCryptographicHash::Sha256};
|
||||
UpdateRelease::Info m_release;
|
||||
QString m_download_error;
|
||||
bool m_busy = false;
|
||||
bool m_installing = false;
|
||||
bool m_game_running = false;
|
||||
bool m_canceled = false;
|
||||
};
|
||||
|
||||
#endif // UPDATE_CHECKER_H
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
#ifndef UPDATE_RELEASE_H
|
||||
#define UPDATE_RELEASE_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
namespace UpdateRelease {
|
||||
|
||||
inline const QString LATEST_URL =
|
||||
QStringLiteral("https://api.github.com/repos/KytyPS5/KytyPS5/releases/latest");
|
||||
inline const QString TAG_URL =
|
||||
QStringLiteral("https://api.github.com/repos/KytyPS5/KytyPS5/releases/tags/%1");
|
||||
inline const QString PAGE_URL =
|
||||
QStringLiteral("https://github.com/KytyPS5/KytyPS5/releases/tag/%1");
|
||||
inline const QString DOWNLOAD_URL =
|
||||
QStringLiteral("https://github.com/KytyPS5/KytyPS5/releases/download/%1/%2");
|
||||
|
||||
struct Info {
|
||||
QString tag;
|
||||
QUrl page_url;
|
||||
QUrl download_url;
|
||||
QByteArray sha256;
|
||||
qint64 size = 0;
|
||||
QDateTime published_at;
|
||||
QString error;
|
||||
};
|
||||
|
||||
enum class VersionRelation { Current, Newer, Older, Unknown };
|
||||
|
||||
[[nodiscard]] QString PlatformSuffix();
|
||||
[[nodiscard]] Info Parse(const QByteArray& data, const QString& platform_suffix);
|
||||
[[nodiscard]] VersionRelation Compare(const Info& latest, const Info& current);
|
||||
|
||||
} // namespace UpdateRelease
|
||||
|
||||
#endif // UPDATE_RELEASE_H
|
||||
|
|
@ -136,6 +136,7 @@ void MainDialogPrivate::Setup(MainDialog* main_dialog) {
|
|||
[this](const QString&) { m_update_checker->Check(true); });
|
||||
connect(m_update_checker, &UpdateChecker::CheckingChanged, m_ui->check_updates_link,
|
||||
&QLabel::setDisabled);
|
||||
connect(m_update_checker, &UpdateChecker::InstallingChanged, this, &MainDialogPrivate::Update);
|
||||
connect(m_ui->check_updates_on_startup, &QCheckBox::toggled, this, [this](bool checked) {
|
||||
g_check_updates_on_startup = checked;
|
||||
m_ui->widget->WriteSettings();
|
||||
|
|
@ -146,6 +147,7 @@ void MainDialogPrivate::Setup(MainDialog* main_dialog) {
|
|||
});
|
||||
|
||||
connect(&m_process, &QProcess::stateChanged, this, [this](QProcess::ProcessState state) {
|
||||
m_update_checker->SetGameRunning(state != QProcess::NotRunning);
|
||||
if (state == QProcess::NotRunning) {
|
||||
if (m_running_item != nullptr) {
|
||||
m_running_item->SetRunning(false);
|
||||
|
|
@ -162,6 +164,7 @@ void MainDialogPrivate::Setup(MainDialog* main_dialog) {
|
|||
}
|
||||
|
||||
void MainDialogPrivate::FindInterpreter() {
|
||||
UpdateChecker::ShowPreviousResult(m_main_dialog);
|
||||
QDir search_dir(QApplication::applicationDirPath());
|
||||
m_interpreter = search_dir.absoluteFilePath(EMULATOR_EXE);
|
||||
|
||||
|
|
@ -509,6 +512,9 @@ void MainDialogPrivate::ReadSettings(QSettings& s) {
|
|||
}
|
||||
|
||||
void MainDialogPrivate::Run() {
|
||||
if (m_update_checker->IsInstalling()) {
|
||||
return;
|
||||
}
|
||||
m_running_item = m_ui->widget->GetSelectedItem();
|
||||
if (m_running_item == nullptr) {
|
||||
return;
|
||||
|
|
@ -526,7 +532,8 @@ void MainDialogPrivate::Run() {
|
|||
void MainDialogPrivate::Update() {
|
||||
const auto* item = m_ui->widget->GetSelectedItem();
|
||||
|
||||
bool run_enabled = (m_process.state() == QProcess::NotRunning && item != nullptr);
|
||||
bool run_enabled = (m_process.state() == QProcess::NotRunning && item != nullptr &&
|
||||
!m_update_checker->IsInstalling());
|
||||
|
||||
if (run_enabled) {
|
||||
const auto& info = item->GetInfo();
|
||||
|
|
|
|||
|
|
@ -2,143 +2,418 @@
|
|||
|
||||
#include "kytyGitVersion.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QAbstractButton>
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QDesktopServices>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <QWidget>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QProgressDialog>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char DEFAULT_FEED_URL[] = "https://kytyps5.github.io/data/updates.json";
|
||||
constexpr char FALLBACK_FEED_URL[] =
|
||||
"https://api.github.com/repos/KytyPS5/KytyPS5/releases/latest";
|
||||
constexpr qint64 MAX_METADATA_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
QString LauncherRelativePath() {
|
||||
#if defined(_WIN32)
|
||||
return QStringLiteral("launcher.exe");
|
||||
#elif defined(__APPLE__)
|
||||
return QStringLiteral("KytyPS5.app/Contents/MacOS/KytyPS5");
|
||||
#else
|
||||
return QStringLiteral("launcher");
|
||||
#endif
|
||||
}
|
||||
|
||||
QString InstallDirectory() {
|
||||
QDir dir(QCoreApplication::applicationDirPath());
|
||||
#if defined(__APPLE__)
|
||||
// Official macOS releases are a bundle; never update a flat development build.
|
||||
if (!dir.cdUp() || !dir.cdUp() || dir.dirName() != QStringLiteral("KytyPS5.app") ||
|
||||
!dir.cdUp()) {
|
||||
return {};
|
||||
}
|
||||
#endif
|
||||
return dir.absolutePath();
|
||||
}
|
||||
|
||||
QString HelperName() {
|
||||
#if defined(_WIN32)
|
||||
return QStringLiteral("kyty_updater.exe");
|
||||
#else
|
||||
return QStringLiteral("kyty_updater");
|
||||
#endif
|
||||
}
|
||||
|
||||
QNetworkRequest Request(const QUrl& url) {
|
||||
QNetworkRequest request(url);
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setRawHeader("User-Agent", "KytyPS5-Updater");
|
||||
request.setTransferTimeout(30000);
|
||||
return request;
|
||||
}
|
||||
|
||||
QString TarProgram() {
|
||||
#if defined(_WIN32)
|
||||
// Use the OS tool, not a tar.exe from the download or current directory.
|
||||
return QDir(qEnvironmentVariable("SystemRoot", QStringLiteral("C:/Windows")))
|
||||
.filePath(QStringLiteral("System32/tar.exe"));
|
||||
#else
|
||||
return QStringLiteral("/usr/bin/tar");
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct UpdateChecker::UpdateInfo {
|
||||
QString tag;
|
||||
QUrl page_url;
|
||||
QString error;
|
||||
};
|
||||
|
||||
UpdateChecker::UpdateChecker(QWidget* parent): QObject(parent), m_parent(parent), m_network(this) {}
|
||||
|
||||
UpdateChecker::~UpdateChecker() {
|
||||
if (m_reply) {
|
||||
m_reply->disconnect(this);
|
||||
m_reply->abort();
|
||||
}
|
||||
// Extraction must stop before QTemporaryDir removes its output.
|
||||
m_prepare.disconnect(this);
|
||||
if (m_prepare.state() != QProcess::NotRunning) {
|
||||
m_prepare.kill();
|
||||
m_prepare.waitForFinished();
|
||||
}
|
||||
}
|
||||
|
||||
bool UpdateChecker::IsSupported() {
|
||||
#if defined(KYTY_OFFICIAL_BUILD) && defined(NDEBUG)
|
||||
return !QString::fromLatin1(KYTY_GIT_HASH).endsWith(QStringLiteral("-dirty"));
|
||||
return !QString::fromLatin1(KYTY_GIT_HASH).endsWith(QStringLiteral("-dirty")) &&
|
||||
!UpdateRelease::PlatformSuffix().isEmpty();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
UpdateChecker::UpdateInfo UpdateChecker::ParseUpdateInfo(const QByteArray& data) {
|
||||
const auto document = QJsonDocument::fromJson(data);
|
||||
if (!document.isObject()) {
|
||||
return {{}, {}, tr("Invalid update feed")};
|
||||
}
|
||||
|
||||
const auto root = document.object();
|
||||
UpdateInfo info {root.value(QStringLiteral("tag")).toString(),
|
||||
QUrl(root.value(QStringLiteral("html_url")).toString()), {}};
|
||||
if (info.tag.isEmpty()) {
|
||||
info.tag = root.value(QStringLiteral("tag_name")).toString();
|
||||
}
|
||||
if (info.tag.isEmpty() || !info.page_url.isValid() ||
|
||||
info.page_url.scheme() != QStringLiteral("https")) {
|
||||
info.error = tr("Incomplete update feed");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
void UpdateChecker::Check(bool manual) {
|
||||
if (!IsSupported() || m_checking_updates) {
|
||||
void UpdateChecker::ShowPreviousResult(QWidget* parent) {
|
||||
const auto directory = InstallDirectory();
|
||||
QFile result(QDir(directory).filePath(QStringLiteral(".kyty-update-result")));
|
||||
if (directory.isEmpty() || !result.open(QIODevice::ReadOnly)) {
|
||||
return;
|
||||
}
|
||||
m_checking_updates = true;
|
||||
emit CheckingChanged(true);
|
||||
FetchUpdateInfo(DEFAULT_FEED_URL, false, manual);
|
||||
const auto status = result.readLine().trimmed();
|
||||
auto message = QString::fromUtf8(result.read(64 * 1024)).trimmed();
|
||||
result.close();
|
||||
result.remove();
|
||||
if (status != "success" && status != "failure") {
|
||||
return;
|
||||
}
|
||||
const bool success = status == "success";
|
||||
if (message.isEmpty()) {
|
||||
message = success ? tr("The update was installed successfully.")
|
||||
: tr("The update could not be installed.");
|
||||
}
|
||||
QMessageBox(success ? QMessageBox::Information : QMessageBox::Warning, tr("KytyPS5 Update"),
|
||||
message, QMessageBox::Ok, parent)
|
||||
.exec();
|
||||
}
|
||||
|
||||
void UpdateChecker::FetchUpdateInfo(const char* url, bool fallback, bool manual) {
|
||||
QNetworkRequest request(QUrl(QString::fromLatin1(url)));
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setRawHeader("User-Agent", "Kyty-Launcher");
|
||||
void UpdateChecker::FetchRelease(const QUrl& url, std::function<void(UpdateRelease::Info)> done) {
|
||||
auto request = Request(url);
|
||||
request.setRawHeader("Accept", "application/vnd.github+json");
|
||||
request.setRawHeader("X-GitHub-Api-Version", "2022-11-28");
|
||||
request.setTransferTimeout(15000);
|
||||
|
||||
auto* reply = m_network.get(request);
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, fallback, manual]() {
|
||||
UpdateInfo info;
|
||||
if (reply->error() == QNetworkReply::NoError) {
|
||||
info = ParseUpdateInfo(reply->readAll());
|
||||
} else {
|
||||
m_reply = reply;
|
||||
reply->setReadBufferSize(MAX_METADATA_SIZE + 1);
|
||||
connect(reply, &QNetworkReply::readyRead, this, [reply]() {
|
||||
if (reply->bytesAvailable() > MAX_METADATA_SIZE) {
|
||||
reply->abort();
|
||||
}
|
||||
});
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, done = std::move(done)]() {
|
||||
UpdateRelease::Info info;
|
||||
if (reply->bytesAvailable() > MAX_METADATA_SIZE) {
|
||||
info.error = tr("The update response is too large.");
|
||||
} else if (reply->error() != QNetworkReply::NoError) {
|
||||
info.error = reply->errorString();
|
||||
} else {
|
||||
info = UpdateRelease::Parse(reply->readAll(), UpdateRelease::PlatformSuffix());
|
||||
}
|
||||
m_reply = nullptr;
|
||||
reply->deleteLater();
|
||||
|
||||
if (!fallback) {
|
||||
const bool up_to_date =
|
||||
info.error.isEmpty() && info.tag == QString::fromLatin1(KYTY_RELEASE_TAG);
|
||||
if (!up_to_date) {
|
||||
if (info.error.isEmpty()) {
|
||||
// The primary feed is reachable but disagrees with the build tag
|
||||
// (e.g. a stale mirror); remember its answer in case the fallback
|
||||
// request also fails.
|
||||
m_primary_tag = info.tag;
|
||||
m_primary_page_url = info.page_url;
|
||||
}
|
||||
FetchUpdateInfo(FALLBACK_FEED_URL, true, manual);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (fallback && !info.error.isEmpty() && !m_primary_tag.isEmpty()) {
|
||||
// The fallback feed failed, but the primary feed already returned a
|
||||
// usable answer; prefer it over reporting a network error.
|
||||
info = {m_primary_tag, m_primary_page_url, {}};
|
||||
}
|
||||
m_primary_tag.clear();
|
||||
m_primary_page_url.clear();
|
||||
|
||||
m_checking_updates = false;
|
||||
emit CheckingChanged(false);
|
||||
ShowUpdateResult(info, manual);
|
||||
done(info);
|
||||
});
|
||||
}
|
||||
|
||||
void UpdateChecker::ShowUpdateResult(const UpdateInfo& info, bool manual) {
|
||||
if (!info.error.isEmpty()) {
|
||||
qWarning() << "Could not check for updates:" << info.error;
|
||||
if (manual) {
|
||||
QMessageBox::warning(m_parent, tr("Update Check"),
|
||||
tr("Could not check for updates:\n%1").arg(info.error));
|
||||
}
|
||||
void UpdateChecker::Check(bool manual) {
|
||||
if (!IsSupported() || m_busy) {
|
||||
return;
|
||||
}
|
||||
m_busy = true;
|
||||
emit CheckingChanged(true);
|
||||
FetchRelease(QUrl(UpdateRelease::LATEST_URL), [this, manual](UpdateRelease::Info latest) {
|
||||
m_release = std::move(latest);
|
||||
if (!m_release.published_at.isValid()) {
|
||||
Finish(m_release.error, manual);
|
||||
} else if (m_release.tag == QString::fromLatin1(KYTY_RELEASE_TAG)) {
|
||||
CheckCurrent(m_release, manual);
|
||||
} else {
|
||||
// Date/hash tags cannot order releases published on the same day.
|
||||
const auto tag = QUrl::toPercentEncoding(QString::fromLatin1(KYTY_RELEASE_TAG));
|
||||
FetchRelease(QUrl(UpdateRelease::TAG_URL.arg(QString::fromLatin1(tag))),
|
||||
[this, manual](const UpdateRelease::Info& current) {
|
||||
CheckCurrent(current, manual);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const bool current = info.tag == QString::fromLatin1(KYTY_RELEASE_TAG);
|
||||
if (current) {
|
||||
if (manual) {
|
||||
QMessageBox::information(m_parent, tr("Update Check"),
|
||||
tr("You are using the latest version (%1).").arg(info.tag));
|
||||
void UpdateChecker::CheckCurrent(const UpdateRelease::Info& current, bool manual) {
|
||||
if (current.tag != QString::fromLatin1(KYTY_RELEASE_TAG) || !current.published_at.isValid()) {
|
||||
Finish(tr("Could not verify the currently installed release. Please try again later."),
|
||||
manual);
|
||||
return;
|
||||
}
|
||||
const auto relation = UpdateRelease::Compare(m_release, current);
|
||||
if (relation == UpdateRelease::VersionRelation::Newer) {
|
||||
if (m_release.error.isEmpty()) {
|
||||
OfferUpdate();
|
||||
} else {
|
||||
Finish(m_release.error, manual);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const auto message =
|
||||
tr("An update is available.\n\nCurrent: %1\nLatest: %2\n\n"
|
||||
"Open the release page?")
|
||||
.arg(QString::fromLatin1(KYTY_RELEASE_TAG), info.tag);
|
||||
if (QMessageBox::question(m_parent, tr("KytyPS5 Update"), message,
|
||||
QMessageBox::Open | QMessageBox::Cancel,
|
||||
QMessageBox::Open) == QMessageBox::Open) {
|
||||
QDesktopServices::openUrl(info.page_url);
|
||||
if (relation == UpdateRelease::VersionRelation::Unknown) {
|
||||
Finish(tr("Could not determine whether the available release is newer."), manual);
|
||||
return;
|
||||
}
|
||||
Finish();
|
||||
if (manual) {
|
||||
QMessageBox::information(m_parent, tr("Update Check"),
|
||||
tr("No newer release is available."));
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateChecker::OfferUpdate() {
|
||||
QMessageBox question(QMessageBox::Information, tr("KytyPS5 Update"),
|
||||
tr("Version %1 is available.\n\nCurrent: %2\n\n"
|
||||
"Download and install it now? The launcher will restart. Your settings "
|
||||
"and games are kept.")
|
||||
.arg(m_release.tag, QString::fromLatin1(KYTY_RELEASE_TAG)),
|
||||
QMessageBox::Yes | QMessageBox::Open | QMessageBox::Cancel, m_parent);
|
||||
question.button(QMessageBox::Yes)->setText(tr("Install Update"));
|
||||
question.button(QMessageBox::Open)->setText(tr("Release Notes"));
|
||||
question.setDefaultButton(QMessageBox::Yes);
|
||||
const auto answer = question.exec();
|
||||
if (answer == QMessageBox::Yes) {
|
||||
Download();
|
||||
return;
|
||||
}
|
||||
if (answer == QMessageBox::Open) {
|
||||
QDesktopServices::openUrl(m_release.page_url);
|
||||
}
|
||||
Finish();
|
||||
}
|
||||
|
||||
void UpdateChecker::Download() {
|
||||
if (m_game_running) {
|
||||
Finish(tr("Close the running game before installing an update."));
|
||||
return;
|
||||
}
|
||||
const auto directory = InstallDirectory();
|
||||
const auto helper = QDir(QCoreApplication::applicationDirPath()).filePath(HelperName());
|
||||
if (directory.isEmpty() || !QFileInfo::exists(helper) || !QFileInfo::exists(TarProgram())) {
|
||||
Finish(tr("This installation cannot update itself. Download the release from GitHub and "
|
||||
"extract it manually."));
|
||||
return;
|
||||
}
|
||||
m_workspace = std::make_unique<QTemporaryDir>(
|
||||
QDir(directory).filePath(QStringLiteral(".kyty-update-XXXXXX")));
|
||||
if (!m_workspace->isValid()) {
|
||||
Finish(tr("The installation folder is not writable. Move KytyPS5 to a writable folder and "
|
||||
"try again."));
|
||||
return;
|
||||
}
|
||||
if (!QFile::copy(helper, m_workspace->filePath(HelperName())) ||
|
||||
!QDir(m_workspace->path()).mkdir(QStringLiteral("payload"))) {
|
||||
Finish(tr("Could not prepare the update folder."));
|
||||
return;
|
||||
}
|
||||
m_download.setFileName(
|
||||
m_workspace->filePath(QStringLiteral("package") + UpdateRelease::PlatformSuffix()));
|
||||
if (!m_download.open(QIODevice::WriteOnly)) {
|
||||
Finish(m_download.errorString());
|
||||
return;
|
||||
}
|
||||
m_hash.reset();
|
||||
m_download_error.clear();
|
||||
m_canceled = false;
|
||||
m_installing = true;
|
||||
emit InstallingChanged(true);
|
||||
m_progress = new QProgressDialog(tr("Downloading %1…").arg(m_release.tag), tr("Cancel"), 0, 100,
|
||||
m_parent);
|
||||
m_progress->setWindowTitle(tr("KytyPS5 Update"));
|
||||
m_progress->setWindowModality(Qt::ApplicationModal);
|
||||
m_progress->setAutoClose(false);
|
||||
m_progress->setAutoReset(false);
|
||||
m_progress->setMinimumDuration(0);
|
||||
connect(m_progress, &QProgressDialog::canceled, this, [this]() {
|
||||
m_canceled = true;
|
||||
if (m_reply) {
|
||||
m_reply->abort();
|
||||
} else if (m_prepare.state() != QProcess::NotRunning) {
|
||||
m_prepare.kill();
|
||||
}
|
||||
});
|
||||
auto* reply = m_network.get(Request(m_release.download_url));
|
||||
m_reply = reply;
|
||||
reply->setReadBufferSize(1024 * 1024);
|
||||
connect(reply, &QNetworkReply::readyRead, this, [this, reply]() { ReadDownload(reply); });
|
||||
connect(reply, &QNetworkReply::downloadProgress, this, [this](qint64 received, qint64) {
|
||||
if (m_progress) {
|
||||
m_progress->setValue(
|
||||
static_cast<int>(qMin<qint64>(99, received * 100 / m_release.size)));
|
||||
}
|
||||
});
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||
ReadDownload(reply);
|
||||
const auto error = reply->error();
|
||||
const auto error_text = reply->errorString();
|
||||
m_reply = nullptr;
|
||||
reply->deleteLater();
|
||||
const auto size = m_download.size();
|
||||
const bool flushed = m_download.flush();
|
||||
m_download.close();
|
||||
if (m_canceled) {
|
||||
Finish();
|
||||
} else if (!m_download_error.isEmpty()) {
|
||||
Finish(m_download_error);
|
||||
} else if (error != QNetworkReply::NoError) {
|
||||
Finish(error_text);
|
||||
} else if (!flushed || size != m_release.size || m_hash.result() != m_release.sha256) {
|
||||
Finish(tr("The update failed its size or SHA-256 verification. Please try again."));
|
||||
} else {
|
||||
PreparePackage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void UpdateChecker::ReadDownload(QNetworkReply* reply) {
|
||||
const auto data = reply->readAll();
|
||||
if (data.isEmpty() || m_canceled || !m_download_error.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (m_download.size() + data.size() > m_release.size) {
|
||||
m_download_error = tr("The downloaded file is larger than the release asset.");
|
||||
} else if (m_download.write(data) != data.size()) {
|
||||
m_download_error = m_download.errorString();
|
||||
} else {
|
||||
m_hash.addData(data);
|
||||
}
|
||||
if (!m_download_error.isEmpty() && !reply->isFinished()) {
|
||||
reply->abort();
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateChecker::PreparePackage() {
|
||||
m_progress->setLabelText(tr("Preparing the update…"));
|
||||
m_progress->setRange(0, 0);
|
||||
RunPreparation(TarProgram(),
|
||||
{QStringLiteral("-xf"), m_download.fileName(), QStringLiteral("-C"),
|
||||
m_workspace->filePath(QStringLiteral("payload")),
|
||||
QStringLiteral("--no-same-owner"), QStringLiteral("--no-same-permissions")},
|
||||
false);
|
||||
}
|
||||
|
||||
void UpdateChecker::RunPreparation(const QString& program, const QStringList& arguments,
|
||||
bool validate) {
|
||||
m_prepare.disconnect(this);
|
||||
m_prepare.setProgram(program);
|
||||
m_prepare.setArguments(arguments);
|
||||
m_prepare.setWorkingDirectory(m_workspace->path());
|
||||
auto environment = QProcessEnvironment::systemEnvironment();
|
||||
environment.remove(QStringLiteral("TAR_OPTIONS"));
|
||||
m_prepare.setProcessEnvironment(environment);
|
||||
connect(&m_prepare, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) {
|
||||
if (error == QProcess::FailedToStart) {
|
||||
Finish(m_prepare.errorString());
|
||||
}
|
||||
});
|
||||
connect(&m_prepare, qOverload<int, QProcess::ExitStatus>(&QProcess::finished), this,
|
||||
[this, validate](int exit_code, QProcess::ExitStatus status) {
|
||||
if (m_canceled) {
|
||||
Finish();
|
||||
} else if (exit_code != 0 || status != QProcess::NormalExit) {
|
||||
Finish(
|
||||
tr("Could not prepare the update:\n%1")
|
||||
.arg(QString::fromUtf8(m_prepare.readAllStandardError()).left(4096)));
|
||||
} else if (!validate) {
|
||||
RunPreparation(m_workspace->filePath(HelperName()),
|
||||
{QStringLiteral("--validate"),
|
||||
m_workspace->filePath(QStringLiteral("payload")),
|
||||
InstallDirectory(), LauncherRelativePath()},
|
||||
true);
|
||||
} else {
|
||||
Restart();
|
||||
}
|
||||
});
|
||||
m_prepare.start();
|
||||
}
|
||||
|
||||
void UpdateChecker::Restart() {
|
||||
if (m_game_running || m_canceled) {
|
||||
Finish(tr("The update was canceled. Close any running game before trying again."));
|
||||
return;
|
||||
}
|
||||
m_progress->setLabelText(tr("Restarting to install the update…"));
|
||||
m_progress->setCancelButton(nullptr);
|
||||
QProcess helper;
|
||||
helper.setProgram(m_workspace->filePath(HelperName()));
|
||||
helper.setArguments({QStringLiteral("--install"),
|
||||
m_workspace->filePath(QStringLiteral("payload")), InstallDirectory(),
|
||||
LauncherRelativePath(),
|
||||
QString::number(QCoreApplication::applicationPid()), QDir::currentPath()});
|
||||
helper.setWorkingDirectory(m_workspace->path());
|
||||
helper.setStandardOutputFile(m_workspace->filePath(QStringLiteral("helper.log")));
|
||||
helper.setStandardErrorFile(m_workspace->filePath(QStringLiteral("helper.log")),
|
||||
QIODevice::Append);
|
||||
if (!helper.startDetached()) {
|
||||
Finish(tr("Could not start the update installer."));
|
||||
return;
|
||||
}
|
||||
m_workspace->setAutoRemove(false);
|
||||
QApplication::quit();
|
||||
}
|
||||
|
||||
void UpdateChecker::Finish(QString error, bool report) {
|
||||
const auto release_page = m_release.page_url;
|
||||
m_release = {};
|
||||
m_download.close();
|
||||
m_workspace.reset();
|
||||
if (m_progress) {
|
||||
m_progress->disconnect(this);
|
||||
m_progress->hide();
|
||||
m_progress->deleteLater();
|
||||
m_progress = nullptr;
|
||||
}
|
||||
m_busy = false;
|
||||
m_installing = false;
|
||||
emit CheckingChanged(false);
|
||||
emit InstallingChanged(false);
|
||||
if (error.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
qWarning() << "KytyPS5 updater:" << error;
|
||||
if (!report) {
|
||||
return;
|
||||
}
|
||||
QMessageBox message(QMessageBox::Warning, tr("KytyPS5 Update"), error, QMessageBox::Close,
|
||||
m_parent);
|
||||
if (!release_page.isEmpty()) {
|
||||
message.addButton(QMessageBox::Open);
|
||||
message.button(QMessageBox::Open)->setText(tr("Open Release Page"));
|
||||
}
|
||||
if (message.exec() == QMessageBox::Open) {
|
||||
QDesktopServices::openUrl(release_page);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
#include "updateRelease.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QRegularExpression>
|
||||
|
||||
namespace UpdateRelease {
|
||||
|
||||
QString PlatformSuffix() {
|
||||
#if defined(_WIN32) && (defined(_M_X64) || defined(__x86_64__))
|
||||
return QStringLiteral("-Windows-x64.zip");
|
||||
#elif defined(__linux__) && defined(__x86_64__)
|
||||
return QStringLiteral("-Linux-x86_64.tar.gz");
|
||||
#elif defined(__APPLE__) && defined(__x86_64__)
|
||||
return QStringLiteral("-macOS-x86_64.zip");
|
||||
#else
|
||||
return {};
|
||||
#endif
|
||||
}
|
||||
|
||||
Info Parse(const QByteArray& data, const QString& platform_suffix) {
|
||||
Info info;
|
||||
const auto document = QJsonDocument::fromJson(data);
|
||||
if (!document.isObject()) {
|
||||
info.error = QObject::tr("Invalid GitHub release response.");
|
||||
return info;
|
||||
}
|
||||
|
||||
const auto root = document.object();
|
||||
info.tag = root.value(QStringLiteral("tag_name")).toString();
|
||||
const auto published_at =
|
||||
QDateTime::fromString(root.value(QStringLiteral("published_at")).toString(), Qt::ISODate);
|
||||
if (info.tag.isEmpty() || !published_at.isValid() ||
|
||||
root.value(QStringLiteral("draft")).toBool() ||
|
||||
root.value(QStringLiteral("prerelease")).toBool()) {
|
||||
info.error = QObject::tr("The release is not a published stable release.");
|
||||
return info;
|
||||
}
|
||||
info.published_at = published_at;
|
||||
const auto encoded_tag = QString::fromLatin1(QUrl::toPercentEncoding(info.tag));
|
||||
info.page_url = QUrl(PAGE_URL.arg(encoded_tag));
|
||||
|
||||
if (platform_suffix.isEmpty()) {
|
||||
info.error = QObject::tr("Built-in updates are unavailable for this platform.");
|
||||
return info;
|
||||
}
|
||||
|
||||
const auto asset_name = info.tag + platform_suffix;
|
||||
for (const auto& value: root.value(QStringLiteral("assets")).toArray()) {
|
||||
const auto asset = value.toObject();
|
||||
if (asset.value(QStringLiteral("name")).toString() != asset_name) {
|
||||
continue;
|
||||
}
|
||||
const auto digest = asset.value(QStringLiteral("digest")).toString();
|
||||
const auto size = asset.value(QStringLiteral("size")).toInteger();
|
||||
static const QRegularExpression digest_pattern(
|
||||
QStringLiteral("\\Asha256:[A-Fa-f0-9]{64}\\z"));
|
||||
if (!digest_pattern.match(digest).hasMatch() || size <= 0) {
|
||||
info.error = QObject::tr("The update package size or SHA-256 checksum is invalid.");
|
||||
return info;
|
||||
}
|
||||
const auto encoded_asset = QString::fromLatin1(QUrl::toPercentEncoding(asset_name));
|
||||
info.download_url = QUrl(DOWNLOAD_URL.arg(encoded_tag, encoded_asset));
|
||||
info.sha256 = QByteArray::fromHex(digest.mid(7).toLatin1());
|
||||
info.size = size;
|
||||
return info;
|
||||
}
|
||||
info.error = QObject::tr("This release has no update package for your platform.");
|
||||
return info;
|
||||
}
|
||||
|
||||
VersionRelation Compare(const Info& latest, const Info& current) {
|
||||
if (latest.tag.isEmpty() || current.tag.isEmpty()) {
|
||||
return VersionRelation::Unknown;
|
||||
}
|
||||
if (latest.tag == current.tag) {
|
||||
return VersionRelation::Current;
|
||||
}
|
||||
if (!latest.published_at.isValid() || !current.published_at.isValid() ||
|
||||
latest.published_at == current.published_at) {
|
||||
return VersionRelation::Unknown;
|
||||
}
|
||||
return latest.published_at > current.published_at ? VersionRelation::Newer
|
||||
: VersionRelation::Older;
|
||||
}
|
||||
|
||||
} // namespace UpdateRelease
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue