mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-04 21:12:42 +08:00
[feat] add in-app updates for desktop installations
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
#include "updater/update_core.h"
|
||||
#include "updater/update_platform.h"
|
||||
#include "platform/single_instance.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#pragma comment(linker, "/subsystem:windows /entry:mainCRTStartup")
|
||||
#endif
|
||||
|
||||
namespace crossdesk::updater {
|
||||
namespace {
|
||||
struct Request {
|
||||
fs::path application;
|
||||
Artifact artifact;
|
||||
};
|
||||
Request ReadRequest(const fs::path& job) {
|
||||
const auto request = nlohmann::json::parse(ReadText(job / "request.json"));
|
||||
const auto application = fs::u8path(request.at("application").get<std::string>());
|
||||
if (!SupportedInstallation(application)) throw std::runtime_error("Invalid installation destination");
|
||||
const auto artifact = VerifyEnvelope(request.at("envelope").get<std::string>(), PublicKey(),
|
||||
PlatformKey(application), BuildVersion());
|
||||
return {application, artifact};
|
||||
}
|
||||
|
||||
int Install(const fs::path& job) {
|
||||
if (!IsPrivileged()) return 2;
|
||||
fs::path stage;
|
||||
int result = 1;
|
||||
try {
|
||||
const auto request = ReadRequest(job);
|
||||
stage = PrivateDirectory();
|
||||
const auto package = stage / request.artifact.filename;
|
||||
ExecuteInstall({
|
||||
[&] {
|
||||
// A privileged installer never executes the caller's writable file.
|
||||
// Copy into private storage, then verify the exact copy being installed.
|
||||
CopyPackage(job / request.artifact.filename, package, request.artifact);
|
||||
},
|
||||
[&] { WaitForApplicationExit(request.application); },
|
||||
[&] { InstallPackage(package, request.application); }});
|
||||
result = 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
}
|
||||
RestoreServices();
|
||||
if (!stage.empty()) {
|
||||
std::error_code ignored;
|
||||
fs::remove_all(stage, ignored);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int Coordinate(const fs::path& job) {
|
||||
fs::path application;
|
||||
bool committed = false;
|
||||
bool success = false;
|
||||
bool exited = false;
|
||||
std::string error;
|
||||
std::unique_ptr<MaintenanceLock> maintenance;
|
||||
try {
|
||||
const auto request = ReadRequest(job);
|
||||
application = request.application;
|
||||
auto pinned = PinHelper(ExecutablePath());
|
||||
{
|
||||
maintenance = std::make_unique<MaintenanceLock>(application);
|
||||
VerifyPackage(job / request.artifact.filename, request.artifact);
|
||||
WriteText(job / "ready", "ready\n");
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(120);
|
||||
while (!fs::exists(job / "commit")) {
|
||||
if (fs::exists(job / "cancel") || std::chrono::steady_clock::now() >= deadline)
|
||||
throw std::runtime_error("Update cancelled before application exit");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
committed = true;
|
||||
platform::SingleInstanceGuard client;
|
||||
platform::SingleInstanceGuard launcher;
|
||||
const auto exit_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(45);
|
||||
while (true) {
|
||||
std::string lease_error;
|
||||
const auto client_result = client.TryAcquire(platform::InstanceRole::kClient, lease_error);
|
||||
const auto launcher_result = launcher.TryAcquire(platform::InstanceRole::kLauncher, lease_error);
|
||||
if (client_result == platform::InstanceResult::kError ||
|
||||
launcher_result == platform::InstanceResult::kError)
|
||||
throw std::runtime_error("Cannot wait for application exit: " + lease_error);
|
||||
if (client_result == platform::InstanceResult::kAcquired &&
|
||||
launcher_result == platform::InstanceResult::kAcquired) break;
|
||||
if (std::chrono::steady_clock::now() >= exit_deadline)
|
||||
throw std::runtime_error("CrossDesk did not exit; no files were installed");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
exited = true;
|
||||
#ifdef _WIN32
|
||||
// This relocated copy is held read-only across UAC and installation.
|
||||
const auto installer_helper = ExecutablePath();
|
||||
#else
|
||||
// Use the root-owned installed helper for privileged execution.
|
||||
const auto installer_helper = HelperPath(application);
|
||||
#endif
|
||||
const int code = RunElevated(installer_helper, job);
|
||||
if (code != 0) throw std::runtime_error("Installation failed or authorization was cancelled (code " +
|
||||
std::to_string(code) + ")");
|
||||
success = true;
|
||||
}
|
||||
} catch (const std::exception& exception) { error = exception.what(); }
|
||||
try {
|
||||
WriteText(job / "result.json", nlohmann::json{{"success", success}, {"error", error}}.dump(2));
|
||||
} catch (const std::exception&) {}
|
||||
maintenance.reset();
|
||||
if (committed && exited) {
|
||||
try {
|
||||
Restart(application);
|
||||
if (success) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(90);
|
||||
while (!fs::exists(job / "started") && std::chrono::steady_clock::now() < deadline)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
if (!fs::exists(job / "started")) {
|
||||
success = false;
|
||||
WriteText(job / "result.json", nlohmann::json{{"success", false},
|
||||
{"error", "Installation finished but the new application did not confirm startup. Open CrossDesk or run the installer manually."}}.dump(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (const std::exception& exception) {
|
||||
success = false;
|
||||
WriteText(job / "result.json", nlohmann::json{{"success", false},
|
||||
{"error", std::string("Could not restart CrossDesk: ") + exception.what()}}.dump(2));
|
||||
}
|
||||
}
|
||||
if (success) {
|
||||
// Keep request/result for diagnostics, remove the large download.
|
||||
std::error_code ignored;
|
||||
for (const auto& entry : fs::directory_iterator(job)) {
|
||||
if (entry.path().extension() == ".pkg" || entry.path().extension() == ".deb" ||
|
||||
(entry.path().extension() == ".exe" && entry.path() != ExecutablePath()))
|
||||
fs::remove(entry.path(), ignored);
|
||||
}
|
||||
}
|
||||
return success ? 0 : 1;
|
||||
}
|
||||
} // namespace
|
||||
} // namespace crossdesk::updater
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
using namespace crossdesk::updater;
|
||||
try {
|
||||
std::vector<std::string> args;
|
||||
#ifdef _WIN32
|
||||
int count = 0;
|
||||
auto wide = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||
if (!wide) return 2;
|
||||
for (int i = 0; i < count; ++i) args.push_back(fs::path(wide[i]).u8string());
|
||||
LocalFree(wide);
|
||||
(void)argc; (void)argv;
|
||||
#else
|
||||
for (int i = 0; i < argc; ++i) args.emplace_back(argv[i]);
|
||||
#endif
|
||||
if (args.size() != 3) return 2;
|
||||
const auto job = fs::u8path(args[2]);
|
||||
if (!job.is_absolute() || fs::is_symlink(job) || !fs::is_directory(job)) return 2;
|
||||
if (args[1] == "--install") return Install(job);
|
||||
if (args[1] == "--coordinate" && OwnedJobDirectory(job)) return Coordinate(job);
|
||||
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; }
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
#include "updater/update_controller.h"
|
||||
#include "updater/update_platform.h"
|
||||
#include "common/filesystem_utf8.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace crossdesk::updater {
|
||||
Controller::Controller(fs::path cache) : cache_(std::move(cache)) {
|
||||
// A previous result survives a restart. Incomplete jobs are never replayed.
|
||||
try {
|
||||
const auto previous = PathFromUtf8(ReadText(cache_ / "last-update", 32768));
|
||||
if (!fs::exists(previous / "result.json")) {
|
||||
state_ = State::Failed;
|
||||
error_ = "The previous update was interrupted. Download and retry.\n" + PathUtf8(previous);
|
||||
return;
|
||||
}
|
||||
const auto result = nlohmann::json::parse(ReadText(previous / "result.json"));
|
||||
if (!result.value("success", false)) {
|
||||
state_ = State::Failed;
|
||||
error_ = result.value("error", std::string("Update did not complete")) +
|
||||
"\n" + PathUtf8(previous);
|
||||
}
|
||||
} catch (const std::exception&) {}
|
||||
}
|
||||
void Controller::ConfirmStartup() {
|
||||
try {
|
||||
const auto previous = PathFromUtf8(ReadText(cache_ / "last-update", 32768));
|
||||
const auto result = nlohmann::json::parse(ReadText(previous / "result.json"));
|
||||
if (!result.value("success", false)) return;
|
||||
const auto request = nlohmann::json::parse(ReadText(previous / "request.json"));
|
||||
const auto artifact = VerifyEnvelope(request.at("envelope").get<std::string>(), PublicKey(),
|
||||
PlatformKey(ExecutablePath()), "0.0.0");
|
||||
auto built = BuildVersion();
|
||||
if (!built.empty() && built[0] == 'v') built.erase(0, 1);
|
||||
if (artifact.version == built) WriteText(previous / "started", "started\n");
|
||||
} catch (const std::exception&) {}
|
||||
}
|
||||
Controller::~Controller() {
|
||||
Cancel();
|
||||
if (pending_.valid()) {
|
||||
try {
|
||||
const auto prepared = pending_.get();
|
||||
WriteText(prepared / "cancel", "cancel\n");
|
||||
} catch (const std::exception&) {}
|
||||
}
|
||||
}
|
||||
bool Controller::Available(const std::string& envelope) const {
|
||||
if (checked_ && checked_envelope_ == envelope) return available_;
|
||||
checked_ = true;
|
||||
checked_envelope_ = envelope;
|
||||
available_ = false;
|
||||
try {
|
||||
const auto application = ExecutablePath();
|
||||
if (!SupportedInstallation(application)) return false;
|
||||
VerifyEnvelope(envelope, PublicKey(), PlatformKey(application), BuildVersion());
|
||||
available_ = true;
|
||||
return available_;
|
||||
} catch (const std::exception&) { return false; }
|
||||
}
|
||||
void Controller::Start(const std::string& envelope) {
|
||||
if (busy() || state_ == State::Ready) return;
|
||||
error_.clear();
|
||||
progress_ = 0;
|
||||
cancel_ = false;
|
||||
state_ = State::Downloading;
|
||||
try {
|
||||
pending_ = std::async(std::launch::async, [this, envelope] {
|
||||
const auto application = ExecutablePath();
|
||||
if (!SupportedInstallation(application)) throw std::runtime_error("Unsupported installation");
|
||||
const auto artifact = VerifyEnvelope(envelope, PublicKey(), PlatformKey(application), BuildVersion());
|
||||
const auto job = PrivateDirectory();
|
||||
try {
|
||||
WriteText(job / "request.json", nlohmann::json{
|
||||
{"application", PathUtf8(application)}, {"envelope", envelope}}.dump());
|
||||
DownloadPackage(artifact, job / artifact.filename, progress_, cancel_);
|
||||
if (cancel_) throw std::runtime_error("Update cancelled");
|
||||
// Relocate the coordinator so package installation can replace the
|
||||
// installed helper too. Its crypto/runtime dependencies are static.
|
||||
const auto helper = job / HelperPath(application).filename();
|
||||
fs::copy_file(HelperPath(application), helper);
|
||||
fs::permissions(helper, fs::perms::owner_all);
|
||||
Launch(helper, {"--coordinate", PathUtf8(job)});
|
||||
return job;
|
||||
} catch (...) {
|
||||
// Only this freshly created directory is owned by this worker.
|
||||
std::error_code ignored;
|
||||
fs::remove_all(job, ignored);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
} catch (const std::exception& exception) {
|
||||
error_ = exception.what();
|
||||
state_ = State::Failed;
|
||||
}
|
||||
}
|
||||
bool Controller::Poll() {
|
||||
try {
|
||||
if (state_ == State::Downloading && pending_.valid() &&
|
||||
pending_.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
|
||||
job_ = pending_.get();
|
||||
deadline_ = std::chrono::steady_clock::now() + std::chrono::seconds(30);
|
||||
state_ = State::Preparing;
|
||||
}
|
||||
if (state_ != State::Preparing) return false;
|
||||
if (cancel_) throw std::runtime_error("Update cancelled");
|
||||
if (fs::exists(job_ / "result.json")) {
|
||||
const auto result = nlohmann::json::parse(ReadText(job_ / "result.json"));
|
||||
throw std::runtime_error(result.value("error", std::string("Update preparation failed")));
|
||||
}
|
||||
if (fs::exists(job_ / "ready")) {
|
||||
WriteText(cache_ / "last-update", PathUtf8(job_));
|
||||
WriteText(job_ / "commit", "install\n");
|
||||
state_ = State::Ready;
|
||||
return true;
|
||||
}
|
||||
if (std::chrono::steady_clock::now() >= deadline_)
|
||||
throw std::runtime_error("Update helper did not become ready");
|
||||
} catch (const std::exception& exception) {
|
||||
const bool cancelled = cancel_.load();
|
||||
if (state_ == State::Preparing) Cancel();
|
||||
error_ = exception.what();
|
||||
state_ = cancelled ? State::Idle : State::Failed;
|
||||
if (cancelled) error_.clear();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void Controller::Cancel() {
|
||||
cancel_ = true;
|
||||
if (state_ == State::Preparing && !job_.empty()) {
|
||||
try { WriteText(job_ / "cancel", "cancel\n"); } catch (const std::exception&) {}
|
||||
}
|
||||
}
|
||||
} // namespace crossdesk::updater
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-20
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _UPDATE_CONTROLLER_H_
|
||||
#define _UPDATE_CONTROLLER_H_
|
||||
|
||||
#include "updater/update_core.h"
|
||||
#include <future>
|
||||
|
||||
namespace crossdesk::updater {
|
||||
class Controller {
|
||||
public:
|
||||
enum class State { Idle, Downloading, Preparing, Ready, Failed };
|
||||
explicit Controller(fs::path cache);
|
||||
~Controller();
|
||||
bool Available(const std::string& envelope) const;
|
||||
void Start(const std::string& envelope);
|
||||
// Returns true only after a live helper has acknowledged the prepared job.
|
||||
bool Poll();
|
||||
void Cancel();
|
||||
void ConfirmStartup();
|
||||
State state() const { return state_; }
|
||||
int progress() const { return progress_.load(); }
|
||||
const std::string& error() const { return error_; }
|
||||
bool busy() const { return state_ == State::Downloading || state_ == State::Preparing; }
|
||||
private:
|
||||
fs::path cache_, job_;
|
||||
State state_ = State::Idle;
|
||||
std::string error_;
|
||||
mutable std::string checked_envelope_;
|
||||
mutable bool checked_ = false;
|
||||
mutable bool available_ = false;
|
||||
std::atomic<int> progress_{0};
|
||||
std::atomic<bool> cancel_{false};
|
||||
std::future<fs::path> pending_;
|
||||
std::chrono::steady_clock::time_point deadline_{};
|
||||
};
|
||||
} // namespace crossdesk::updater
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,267 @@
|
||||
#include "updater/update_core.h"
|
||||
|
||||
#include <app_version.h>
|
||||
#include <httplib.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
#include <array>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
#ifndef CROSSDESK_UPDATE_PUBLIC_KEY
|
||||
#define CROSSDESK_UPDATE_PUBLIC_KEY ""
|
||||
#endif
|
||||
#ifndef CROSSDESK_VERSION
|
||||
#define CROSSDESK_VERSION "0.0.0"
|
||||
#endif
|
||||
|
||||
namespace crossdesk::updater {
|
||||
namespace {
|
||||
constexpr std::uint64_t kMaxPackageSize = 2ULL * 1024 * 1024 * 1024;
|
||||
constexpr const char* kDownloadOrigin = "https://downloads.crossdesk.cn";
|
||||
|
||||
std::string Hex(const unsigned char* data, std::size_t length) {
|
||||
constexpr char digits[] = "0123456789abcdef";
|
||||
std::string result;
|
||||
for (std::size_t i = 0; i < length; ++i) {
|
||||
result += digits[data[i] >> 4];
|
||||
result += digits[data[i] & 15];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<unsigned char> Unhex(const std::string& value, std::size_t size) {
|
||||
if (value.size() != size * 2) throw std::runtime_error("Invalid signature/key size");
|
||||
std::vector<unsigned char> result(size);
|
||||
auto digit = [](char c) -> int {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
throw std::runtime_error("Expected lowercase hexadecimal");
|
||||
};
|
||||
for (std::size_t i = 0; i < size; ++i)
|
||||
result[i] = static_cast<unsigned char>(digit(value[2 * i]) * 16 + digit(value[2 * i + 1]));
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string ExpectedFilename(const std::string& platform, const std::string& version) {
|
||||
if (version.empty() || version.find_first_not_of("0123456789.-") != std::string::npos)
|
||||
throw std::runtime_error("Invalid release filename version");
|
||||
if (platform == "windows-x64") return "crossdesk-win-x64-v" + version + ".exe";
|
||||
if (platform == "windows-x64-portable")
|
||||
return "crossdesk-win-x64-portable-v" + version + ".zip";
|
||||
if (platform == "macos-x64" || platform == "macos-arm64")
|
||||
return "crossdesk-" + platform + "-v" + version + ".pkg";
|
||||
for (const std::string arch : {"amd64", "arm64"}) {
|
||||
if (platform == "linux-" + arch)
|
||||
return "crossdesk-linux-" + arch + "-v" + version + ".deb";
|
||||
if (platform == "linux-" + arch + "-virtual-desktop-xfce")
|
||||
return "crossdesk-virtual-desktop-xfce-linux-" + arch + "-v" + version + ".deb";
|
||||
}
|
||||
throw std::runtime_error("Unsupported update platform");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string PublicKey() { return CROSSDESK_UPDATE_PUBLIC_KEY; }
|
||||
std::string BuildVersion() { return CROSSDESK_VERSION; }
|
||||
std::string PathUtf8(const fs::path& path) { return path.u8string(); }
|
||||
|
||||
Artifact VerifyEnvelope(const std::string& envelope, const std::string& public_key,
|
||||
const std::string& platform, const std::string& current_version) {
|
||||
if (envelope.size() > 65536) throw std::runtime_error("Update envelope is too large");
|
||||
const auto wrapper = nlohmann::json::parse(envelope);
|
||||
const auto payload = wrapper.at("payload").get<std::string>();
|
||||
const auto signature = Unhex(wrapper.at("signature").get<std::string>(), 64);
|
||||
const auto key_bytes = Unhex(public_key, 32);
|
||||
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
|
||||
EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, key_bytes.data(), key_bytes.size()),
|
||||
EVP_PKEY_free);
|
||||
std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)> context(EVP_MD_CTX_new(), EVP_MD_CTX_free);
|
||||
if (!key || !context || EVP_DigestVerifyInit(context.get(), nullptr, nullptr, nullptr, key.get()) != 1 ||
|
||||
EVP_DigestVerify(context.get(), signature.data(), signature.size(),
|
||||
reinterpret_cast<const unsigned char*>(payload.data()), payload.size()) != 1)
|
||||
throw std::runtime_error("Update signature verification failed");
|
||||
const auto value = nlohmann::json::parse(payload);
|
||||
if (value.at("schema") != 1) throw std::runtime_error("Unsupported update schema");
|
||||
Artifact artifact;
|
||||
artifact.platform = value.at("platform").get<std::string>();
|
||||
artifact.version = value.at("version").get<std::string>();
|
||||
artifact.filename = value.at("filename").get<std::string>();
|
||||
artifact.url = value.at("url").get<std::string>();
|
||||
artifact.sha256 = value.at("sha256").get<std::string>();
|
||||
if (!value.at("size").is_number_unsigned()) throw std::runtime_error("Invalid package size");
|
||||
artifact.size = value.at("size").get<std::uint64_t>();
|
||||
if (artifact.platform != platform || platform.find("portable") != std::string::npos ||
|
||||
!IsValidAppVersion(current_version) || !IsValidAppVersion(artifact.version) ||
|
||||
!IsNewerVersion(current_version, artifact.version))
|
||||
throw std::runtime_error("Update platform/version does not match this installation");
|
||||
if (!artifact.size || artifact.size > kMaxPackageSize ||
|
||||
artifact.filename != ExpectedFilename(platform, artifact.version) ||
|
||||
artifact.url != std::string(kDownloadOrigin) + "/" + artifact.filename)
|
||||
throw std::runtime_error("Invalid update package metadata");
|
||||
Unhex(artifact.sha256, 32);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
std::string PlatformKey(const fs::path& executable, bool portable) {
|
||||
#if defined(_WIN32) && (defined(_M_X64) || defined(__x86_64__))
|
||||
(void)executable;
|
||||
return portable ? "windows-x64-portable" : "windows-x64";
|
||||
#elif defined(__APPLE__)
|
||||
(void)executable; (void)portable;
|
||||
#if defined(__aarch64__) || defined(__arm64__)
|
||||
return "macos-arm64";
|
||||
#else
|
||||
return "macos-x64";
|
||||
#endif
|
||||
#elif defined(__linux__) && !defined(__OHOS__)
|
||||
(void)portable;
|
||||
#if defined(__aarch64__)
|
||||
std::string key = "linux-arm64";
|
||||
#elif defined(__x86_64__)
|
||||
std::string key = "linux-amd64";
|
||||
#else
|
||||
return {};
|
||||
#endif
|
||||
std::ifstream marker(executable.parent_path() / "package-name");
|
||||
std::string package;
|
||||
if (!std::getline(marker, package)) return {};
|
||||
if (package == "crossdesk-virtual-desktop-xfce") key += "-virtual-desktop-xfce";
|
||||
else if (package != "crossdesk") return {};
|
||||
return key;
|
||||
#else
|
||||
(void)executable; (void)portable;
|
||||
return {};
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string DownloadUrl(const std::string& manifest, const std::string& platform,
|
||||
const std::string& version) {
|
||||
try {
|
||||
const auto download = nlohmann::json::parse(manifest).at("downloads").at(platform);
|
||||
const auto filename = ExpectedFilename(platform, version);
|
||||
const auto url = std::string(kDownloadOrigin) + "/" + filename;
|
||||
if (download.at("version") == version && download.at("filename") == filename &&
|
||||
download.at("url") == url) return url;
|
||||
} catch (const std::exception&) {}
|
||||
return "https://crossdesk.cn";
|
||||
}
|
||||
|
||||
std::string SignedDownload(const std::string& manifest, const std::string& platform) {
|
||||
try {
|
||||
return nlohmann::json::parse(manifest).at("downloads").at(platform).at("update").dump();
|
||||
} catch (const std::exception&) { return {}; }
|
||||
}
|
||||
|
||||
std::string Sha256(const std::string& value) {
|
||||
unsigned char digest[EVP_MAX_MD_SIZE];
|
||||
unsigned int size = 0;
|
||||
if (EVP_Digest(value.data(), value.size(), digest, &size, EVP_sha256(), nullptr) != 1)
|
||||
throw std::runtime_error("SHA-256 failed");
|
||||
return Hex(digest, size);
|
||||
}
|
||||
|
||||
void VerifyPackage(const fs::path& file, const Artifact& artifact) {
|
||||
std::ifstream input(file, std::ios::binary);
|
||||
std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)> context(EVP_MD_CTX_new(), EVP_MD_CTX_free);
|
||||
if (!input || !context || EVP_DigestInit_ex(context.get(), EVP_sha256(), nullptr) != 1)
|
||||
throw std::runtime_error("Cannot read update package");
|
||||
std::array<char, 65536> buffer{};
|
||||
std::uint64_t total = 0;
|
||||
while (input.read(buffer.data(), buffer.size()) || input.gcount()) {
|
||||
const auto size = static_cast<std::size_t>(input.gcount());
|
||||
total += size;
|
||||
if (total > artifact.size || EVP_DigestUpdate(context.get(), buffer.data(), size) != 1)
|
||||
throw std::runtime_error("Update package size/hash failed");
|
||||
}
|
||||
unsigned char digest[EVP_MAX_MD_SIZE];
|
||||
unsigned int size = 0;
|
||||
if (!input.eof() || total != artifact.size ||
|
||||
EVP_DigestFinal_ex(context.get(), digest, &size) != 1 || Hex(digest, size) != artifact.sha256)
|
||||
throw std::runtime_error("Update package verification failed");
|
||||
}
|
||||
|
||||
void DownloadPackage(const Artifact& artifact, const fs::path& destination,
|
||||
std::atomic<int>& progress, const std::atomic<bool>& cancel) {
|
||||
httplib::Client client(kDownloadOrigin);
|
||||
client.set_connection_timeout(10);
|
||||
client.set_read_timeout(30);
|
||||
client.set_write_timeout(10);
|
||||
client.set_max_timeout(30 * 60 * 1000);
|
||||
client.set_follow_location(false);
|
||||
#if defined(__linux__)
|
||||
for (const char* bundle : {"/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"}) {
|
||||
if (fs::exists(bundle)) { client.set_ca_cert_path(bundle); break; }
|
||||
}
|
||||
#endif
|
||||
std::ofstream file(destination, std::ios::binary | std::ios::trunc);
|
||||
if (!file) throw std::runtime_error("Cannot create update download");
|
||||
std::uint64_t received = 0;
|
||||
const auto response = client.Get(("/" + artifact.filename).c_str(),
|
||||
[&](const httplib::Response& headers) {
|
||||
return headers.status == 200 && !cancel.load();
|
||||
},
|
||||
[&](const char* data, std::size_t length) {
|
||||
if (cancel.load() || length > artifact.size - received) return false;
|
||||
file.write(data, static_cast<std::streamsize>(length));
|
||||
received += length;
|
||||
progress.store(static_cast<int>(received * 100 / artifact.size));
|
||||
return static_cast<bool>(file);
|
||||
});
|
||||
file.close();
|
||||
if (!response || response->status != 200 || !file || received != artifact.size || cancel.load())
|
||||
throw std::runtime_error(cancel.load() ? "Update cancelled" : "Update download failed");
|
||||
VerifyPackage(destination, artifact);
|
||||
}
|
||||
|
||||
void CopyPackage(const fs::path& source, const fs::path& destination, const Artifact& artifact) {
|
||||
std::ifstream input(source, std::ios::binary);
|
||||
std::ofstream output(destination, std::ios::binary | std::ios::trunc);
|
||||
if (!input || !output) throw std::runtime_error("Cannot stage installation package");
|
||||
std::array<char, 65536> buffer{};
|
||||
std::uint64_t copied = 0;
|
||||
while (input.read(buffer.data(), buffer.size()) || input.gcount()) {
|
||||
const auto length = static_cast<std::uint64_t>(input.gcount());
|
||||
if (length > artifact.size - copied) throw std::runtime_error("Installation package is too large");
|
||||
output.write(buffer.data(), input.gcount());
|
||||
if (!output) throw std::runtime_error("Cannot write installation package");
|
||||
copied += length;
|
||||
}
|
||||
output.close();
|
||||
if (!input.eof() || !output || copied != artifact.size)
|
||||
throw std::runtime_error("Installation package is incomplete");
|
||||
VerifyPackage(destination, artifact);
|
||||
}
|
||||
|
||||
std::string ReadText(const fs::path& file, std::size_t maximum) {
|
||||
std::ifstream input(file, std::ios::binary);
|
||||
if (!input) throw std::runtime_error("Cannot read update metadata");
|
||||
std::string value(maximum + 1, '\0');
|
||||
input.read(value.data(), static_cast<std::streamsize>(value.size()));
|
||||
value.resize(static_cast<std::size_t>(input.gcount()));
|
||||
if (value.size() > maximum || input.bad()) throw std::runtime_error("Invalid update metadata size");
|
||||
return value;
|
||||
}
|
||||
|
||||
void WriteText(const fs::path& file, const std::string& text) {
|
||||
std::ofstream output(file, std::ios::binary | std::ios::trunc);
|
||||
output << text;
|
||||
output.close();
|
||||
if (!output) throw std::runtime_error("Cannot write update state");
|
||||
}
|
||||
|
||||
std::string RandomId() {
|
||||
unsigned char bytes[16];
|
||||
if (RAND_bytes(bytes, sizeof(bytes)) != 1) throw std::runtime_error("Cannot generate update ID");
|
||||
return Hex(bytes, sizeof(bytes));
|
||||
}
|
||||
|
||||
void ExecuteInstall(const InstallActions& actions) {
|
||||
actions.verify();
|
||||
actions.wait_for_exit();
|
||||
actions.install();
|
||||
}
|
||||
} // namespace crossdesk::updater
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-20
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _UPDATE_CORE_H_
|
||||
#define _UPDATE_CORE_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::updater {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
struct Artifact {
|
||||
std::string platform;
|
||||
std::string version;
|
||||
std::string filename;
|
||||
std::string url;
|
||||
std::string sha256;
|
||||
std::uint64_t size = 0;
|
||||
};
|
||||
|
||||
// The envelope contains the exact signed UTF-8 payload and a hex Ed25519 signature.
|
||||
// Never reserialize the payload before verifying it.
|
||||
Artifact VerifyEnvelope(const std::string& envelope, const std::string& public_key,
|
||||
const std::string& platform,
|
||||
const std::string& current_version);
|
||||
std::string PublicKey();
|
||||
std::string BuildVersion();
|
||||
std::string PlatformKey(const fs::path& executable, bool portable = false);
|
||||
std::string DownloadUrl(const std::string& manifest, const std::string& platform,
|
||||
const std::string& version);
|
||||
std::string SignedDownload(const std::string& manifest, const std::string& platform);
|
||||
std::string Sha256(const std::string& value);
|
||||
void VerifyPackage(const fs::path& file, const Artifact& artifact);
|
||||
void CopyPackage(const fs::path& source, const fs::path& destination, const Artifact& artifact);
|
||||
void DownloadPackage(const Artifact& artifact, const fs::path& destination,
|
||||
std::atomic<int>& progress, const std::atomic<bool>& cancel);
|
||||
std::string ReadText(const fs::path& file, std::size_t maximum = 65536);
|
||||
void WriteText(const fs::path& file, const std::string& text);
|
||||
fs::path PrivateDirectory();
|
||||
std::string RandomId();
|
||||
// ABI boundary between the C++17 core and C++20 GUI (char8_t).
|
||||
std::string PathUtf8(const fs::path& path);
|
||||
|
||||
// Files and installation operations are injectable so failure ordering is tested
|
||||
// without invoking an actual installer.
|
||||
struct InstallActions {
|
||||
std::function<void()> verify;
|
||||
std::function<void()> wait_for_exit;
|
||||
std::function<void()> install;
|
||||
};
|
||||
void ExecuteInstall(const InstallActions& actions);
|
||||
} // namespace crossdesk::updater
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-20
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _UPDATE_PLATFORM_H_
|
||||
#define _UPDATE_PLATFORM_H_
|
||||
|
||||
#include "updater/update_core.h"
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::updater {
|
||||
fs::path ExecutablePath();
|
||||
fs::path HelperPath(const fs::path& application);
|
||||
bool SupportedInstallation(const fs::path& application);
|
||||
bool IsPrivileged();
|
||||
bool OwnedJobDirectory(const fs::path& job);
|
||||
void Launch(const fs::path& executable, const std::vector<std::string>& arguments);
|
||||
int Run(const fs::path& executable, const std::vector<std::string>& arguments,
|
||||
bool elevate = false);
|
||||
int RunElevated(const fs::path& executable, const fs::path& job);
|
||||
void WaitForApplicationExit(const fs::path& application);
|
||||
void InstallPackage(const fs::path& package, const fs::path& application);
|
||||
void Restart(const fs::path& application);
|
||||
void RestoreServices();
|
||||
// Pin relocated helper files against changes during elevation (Windows).
|
||||
std::shared_ptr<void> PinHelper(const fs::path& helper);
|
||||
|
||||
class MaintenanceLock {
|
||||
public:
|
||||
explicit MaintenanceLock(const fs::path& application);
|
||||
~MaintenanceLock();
|
||||
MaintenanceLock(const MaintenanceLock&) = delete;
|
||||
MaintenanceLock& operator=(const MaintenanceLock&) = delete;
|
||||
private:
|
||||
std::intptr_t handle_ = -1;
|
||||
};
|
||||
bool MaintenanceActive(const fs::path& application);
|
||||
} // namespace crossdesk::updater
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user