[feat] add in-app updates for desktop installations

This commit is contained in:
dijunkun
2026-10-20 20:11:12 +08:00
parent 62ba3693af
commit a625890b28
34 changed files with 1732 additions and 25 deletions
@@ -0,0 +1,134 @@
#include "updater/update_platform.h"
#include <fcntl.h>
#include <signal.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cerrno>
#include <chrono>
#include <cstdlib>
#include <stdexcept>
#include <thread>
namespace crossdesk::updater {
namespace {
std::string LockPath(const fs::path& application) {
return "/tmp/CrossDesk-update-" + Sha256(fs::weakly_canonical(application).string()) + ".lock";
}
std::vector<char*> Argv(std::vector<std::string>& args) {
std::vector<char*> values;
for (auto& argument : args) values.push_back(argument.data());
values.push_back(nullptr);
return values;
}
int OpenLock(const std::string& path, bool create) {
constexpr int flags = O_RDONLY | O_CLOEXEC | O_NOFOLLOW;
int fd = open(path.c_str(), flags | (create ? O_CREAT | O_EXCL : 0), 0644);
if (fd < 0 && create && errno == EEXIST) fd = open(path.c_str(), flags);
if (fd >= 0) {
struct stat info{};
if (fstat(fd, &info) || !S_ISREG(info.st_mode)) {
close(fd); errno = EINVAL; return -1;
}
// A restrictive umask must not leave a stale lock file that permanently
// prevents other users from probing whether an update is active.
if (create && info.st_uid == geteuid() && fchmod(fd, 0644)) {
const int error = errno; close(fd); errno = error; return -1;
}
}
return fd;
}
} // namespace
bool IsPrivileged() { return geteuid() == 0; }
bool OwnedJobDirectory(const fs::path& job) {
struct stat info{};
return lstat(job.c_str(), &info) == 0 && S_ISDIR(info.st_mode) &&
info.st_uid == geteuid() && (info.st_mode & 0077) == 0;
}
fs::path PrivateDirectory() {
// Ignore TMPDIR in privileged processes. mkdtemp creates a private 0700
// directory; installation only consumes a verified copy inside this directory.
std::string pattern = "/tmp/crossdesk-update-XXXXXX";
if (!mkdtemp(pattern.data())) throw std::runtime_error("Cannot create private update directory");
return fs::path(pattern);
}
fs::path HelperPath(const fs::path& application) {
return application.parent_path() / "crossdesk_updater";
}
std::shared_ptr<void> PinHelper(const fs::path&) { return {}; }
void RestoreServices() {}
int Run(const fs::path& executable, const std::vector<std::string>& arguments, bool elevate) {
// Platform-specific elevation builds a fixed argv and calls this with false.
if (elevate) throw std::runtime_error("Use platform elevation entry point");
std::vector<std::string> args{executable.string()};
args.insert(args.end(), arguments.begin(), arguments.end());
auto argv = Argv(args);
const pid_t pid = fork();
if (pid < 0) throw std::runtime_error("Cannot start installer");
if (pid == 0) {
execv(executable.c_str(), argv.data());
_exit(127);
}
int status = 0;
pid_t result;
do { result = waitpid(pid, &status, 0); } while (result < 0 && errno == EINTR);
return result == pid && WIFEXITED(status) ? WEXITSTATUS(status) : 1;
}
void Launch(const fs::path& executable, const std::vector<std::string>& arguments) {
std::vector<std::string> args{executable.string()};
args.insert(args.end(), arguments.begin(), arguments.end());
auto argv = Argv(args);
int errors[2];
if (pipe(errors)) throw std::runtime_error("Cannot create launch pipe");
fcntl(errors[0], F_SETFD, FD_CLOEXEC);
fcntl(errors[1], F_SETFD, FD_CLOEXEC);
const pid_t child = fork();
if (child == 0) {
close(errors[0]);
if (setsid() < 0) _exit(127);
const pid_t detached = fork();
if (detached < 0) _exit(127);
if (detached > 0) _exit(0);
const int null = open("/dev/null", O_RDWR);
if (null >= 0) {
dup2(null, STDIN_FILENO); dup2(null, STDOUT_FILENO); dup2(null, STDERR_FILENO);
if (null > STDERR_FILENO) close(null);
}
execv(executable.c_str(), argv.data());
const int error = errno;
(void)write(errors[1], &error, sizeof(error));
_exit(127);
}
close(errors[1]);
if (child < 0) { close(errors[0]); throw std::runtime_error("Cannot launch update helper"); }
int status = 0;
while (waitpid(child, &status, 0) < 0 && errno == EINTR) {}
int error = 0;
ssize_t bytes;
do { bytes = read(errors[0], &error, sizeof(error)); } while (bytes < 0 && errno == EINTR);
close(errors[0]);
if (bytes != 0 || !WIFEXITED(status) || WEXITSTATUS(status) != 0)
throw std::runtime_error("Cannot execute update helper");
}
MaintenanceLock::MaintenanceLock(const fs::path& application) {
const int fd = OpenLock(LockPath(application), true);
if (fd < 0) throw std::runtime_error("Cannot open update lock");
if (flock(fd, LOCK_EX | LOCK_NB)) { close(fd); throw std::runtime_error("An update is already running"); }
handle_ = fd;
}
MaintenanceLock::~MaintenanceLock() { if (handle_ != -1) close(static_cast<int>(handle_)); }
bool MaintenanceActive(const fs::path& application) {
const int fd = OpenLock(LockPath(application), false);
if (fd < 0) return errno != ENOENT;
const bool locked = flock(fd, LOCK_SH | LOCK_NB) != 0;
close(fd);
return locked;
}
} // namespace crossdesk::updater
@@ -0,0 +1,54 @@
#include "updater/update_platform.h"
#include <unistd.h>
#include <sys/stat.h>
#include <chrono>
#include <cstdlib>
#include <fstream>
#include <stdexcept>
#include <thread>
namespace crossdesk::updater {
fs::path ExecutablePath() { return fs::read_symlink("/proc/self/exe"); }
bool SupportedInstallation(const fs::path& application) {
return application == fs::path("/usr/lib/crossdesk/crossdesk-bin") &&
!PlatformKey(application).empty() && fs::is_regular_file(HelperPath(application)) &&
access("/usr/bin/pkexec", X_OK) == 0 && access("/usr/bin/apt-get", X_OK) == 0;
}
int RunElevated(const fs::path& executable, const fs::path& job) {
return Run("/usr/bin/pkexec", {executable.string(), "--install", job.string()});
}
void WaitForApplicationExit(const fs::path& application) {
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(45);
do {
bool running = false;
for (const auto& entry : fs::directory_iterator("/proc")) {
const auto name = entry.path().filename().string();
if (name.empty() || name.find_first_not_of("0123456789") != std::string::npos) continue;
std::error_code error;
const auto executable = fs::read_symlink(entry.path() / "exe", error);
if (!error && executable == application) running = true;
// Fail closed when a matching process cannot be inspected. A disappeared
// process has no comm file and does not block installation.
if (error) {
std::ifstream comm(entry.path() / "comm");
std::string process_name;
std::getline(comm, process_name);
if (process_name == "crossdesk-bin") running = true;
}
}
if (!running) return;
std::this_thread::sleep_for(std::chrono::milliseconds(200));
} while (std::chrono::steady_clock::now() < deadline);
throw std::runtime_error("CrossDesk is still running; stop other sessions or its supervisor and retry");
}
void InstallPackage(const fs::path& package, const fs::path&) {
// No shell and no automatic dependency removal. apt owns dpkg locking and
// dependency resolution; a busy/broken package manager is reported as failure.
setenv("DEBIAN_FRONTEND", "noninteractive", 1);
if (Run("/usr/bin/apt-get", {"install", "--yes", "--no-remove",
"-o", "Dpkg::Options::=--force-confold", package.string()}) != 0)
throw std::runtime_error("The package manager could not install the update");
}
void Restart(const fs::path&) { Launch("/usr/bin/crossdesk", {}); }
} // namespace crossdesk::updater
@@ -0,0 +1,72 @@
#include "updater/update_platform.h"
#include <libproc.h>
#include <mach-o/dyld.h>
#include <unistd.h>
#include <chrono>
#include <stdexcept>
#include <thread>
namespace crossdesk::updater {
namespace {
std::string ShellQuote(const std::string& value) {
std::string result = "'";
for (char c : value) result += c == '\'' ? "'\\''" : std::string(1, c);
return result + "'";
}
std::string AppleScriptString(const std::string& value) {
std::string result = "\"";
for (char c : value) {
if (c == '\\' || c == '"') result += '\\';
result += c;
}
return result + "\"";
}
} // namespace
fs::path ExecutablePath() {
uint32_t size = 0;
_NSGetExecutablePath(nullptr, &size);
std::vector<char> buffer(size);
if (_NSGetExecutablePath(buffer.data(), &size)) throw std::runtime_error("Cannot locate executable");
return fs::canonical(buffer.data());
}
bool SupportedInstallation(const fs::path& application) {
return application == fs::path("/Applications/CrossDesk.app/Contents/MacOS/CrossDesk") &&
fs::is_regular_file(HelperPath(application));
}
int RunElevated(const fs::path& executable, const fs::path& job) {
const auto command = ShellQuote(executable.string()) + " --install " + ShellQuote(job.string());
return Run("/usr/bin/osascript", {"-e", "do shell script " +
AppleScriptString(command) + " with administrator privileges"});
}
void WaitForApplicationExit(const fs::path& application) {
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(45);
do {
const int count = proc_listallpids(nullptr, 0);
if (count <= 0) throw std::runtime_error("Cannot enumerate CrossDesk processes");
std::vector<pid_t> pids(static_cast<std::size_t>(count) + 256);
const int length = proc_listallpids(pids.data(), static_cast<int>(pids.size() * sizeof(pid_t)));
if (length <= 0 || length >= static_cast<int>(pids.size()))
throw std::runtime_error("Cannot inspect all CrossDesk processes");
bool running = false;
for (int i = 0; i < length; ++i) {
char path[PROC_PIDPATHINFO_MAXSIZE]{};
if (proc_pidpath(pids[i], path, sizeof(path)) > 0) {
if (fs::path(path) == application) running = true;
} else {
char name[1024]{};
if (proc_name(pids[i], name, sizeof(name)) > 0 && std::string(name) == "CrossDesk")
running = true;
}
}
if (!running) return;
std::this_thread::sleep_for(std::chrono::milliseconds(200));
} while (std::chrono::steady_clock::now() < deadline);
throw std::runtime_error("CrossDesk is still running in another session");
}
void InstallPackage(const fs::path& package, const fs::path&) {
if (Run("/usr/sbin/installer", {"-pkg", package.string(), "-target", "/"}) != 0)
throw std::runtime_error("The macOS installer could not install the update");
}
void Restart(const fs::path&) { Launch("/usr/bin/open", {"/Applications/CrossDesk.app"}); }
} // namespace crossdesk::updater
@@ -0,0 +1,303 @@
#include "updater/update_platform.h"
#include <windows.h>
#include <shellapi.h>
#include <shlobj.h>
#include <sddl.h>
#include <aclapi.h>
#include <tlhelp32.h>
#include <algorithm>
#include <chrono>
#include <cwctype>
#include <stdexcept>
#include <thread>
namespace crossdesk::updater {
namespace {
bool service_was_running = false;
std::wstring Wide(const std::string& text) { return fs::u8path(text).wstring(); }
std::wstring Quote(const std::wstring& value) {
std::wstring result = L"\"";
unsigned slashes = 0;
for (wchar_t c : value) {
if (c == L'\\') { ++slashes; continue; }
result.append(c == L'"' ? slashes * 2 + 1 : slashes, L'\\');
slashes = 0;
result += c;
}
result.append(slashes * 2, L'\\');
return result + L"\"";
}
std::wstring Arguments(const std::vector<std::string>& args) {
std::wstring result;
for (const auto& argument : args) {
if (!result.empty()) result += L' ';
result += Quote(Wide(argument));
}
return result;
}
std::wstring Normalized(const fs::path& path) {
auto value = fs::weakly_canonical(path).wstring();
std::transform(value.begin(), value.end(), value.begin(), towlower);
return value;
}
std::wstring LockName(const fs::path& app) {
return L"Global\\CrossDeskUpdate-" + Wide(Sha256(fs::path(Normalized(app)).u8string()));
}
void StopService() {
SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT);
if (!manager) throw std::runtime_error("Cannot query CrossDesk service");
SC_HANDLE service = OpenServiceW(manager, L"CrossDeskService", SERVICE_STOP | SERVICE_QUERY_STATUS);
const DWORD error = GetLastError();
CloseServiceHandle(manager);
if (!service) {
if (error == ERROR_SERVICE_DOES_NOT_EXIST) return;
throw std::runtime_error("Cannot open CrossDesk service");
}
SERVICE_STATUS_PROCESS status{};
DWORD bytes = 0;
if (!QueryServiceStatusEx(service, SC_STATUS_PROCESS_INFO,
reinterpret_cast<BYTE*>(&status), sizeof(status), &bytes)) {
CloseServiceHandle(service);
throw std::runtime_error("Cannot query CrossDesk service state");
}
service_was_running = status.dwCurrentState == SERVICE_RUNNING ||
status.dwCurrentState == SERVICE_START_PENDING;
SERVICE_STATUS ignored{};
if (status.dwCurrentState != SERVICE_STOPPED && status.dwCurrentState != SERVICE_STOP_PENDING &&
!ControlService(service, SERVICE_CONTROL_STOP, &ignored)) {
CloseServiceHandle(service);
throw std::runtime_error("Cannot stop CrossDesk service");
}
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
while (std::chrono::steady_clock::now() < deadline) {
if (!QueryServiceStatusEx(service, SC_STATUS_PROCESS_INFO,
reinterpret_cast<BYTE*>(&status), sizeof(status), &bytes)) break;
if (status.dwCurrentState == SERVICE_STOPPED) {
CloseServiceHandle(service);
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
CloseServiceHandle(service);
throw std::runtime_error("CrossDesk service did not stop");
}
} // namespace
fs::path ExecutablePath() {
wchar_t buffer[32768];
const DWORD size = GetModuleFileNameW(nullptr, buffer, 32768);
if (!size || size >= 32768) throw std::runtime_error("Cannot locate executable");
return fs::path(std::wstring(buffer, size));
}
fs::path HelperPath(const fs::path& application) {
return application.parent_path() / L"crossdesk_updater.exe";
}
bool SupportedInstallation(const fs::path& application) {
// Only the installer owns this value. Portable/development directories cannot
// become destinations for an elevated update.
wchar_t directory[32768];
DWORD bytes = sizeof(directory);
if (RegGetValueW(HKEY_LOCAL_MACHINE,
L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\CrossDesk",
L"InstallLocation", RRF_RT_REG_SZ | RRF_SUBKEY_WOW6432KEY, nullptr,
directory, &bytes) != ERROR_SUCCESS) return false;
return Normalized(fs::path(directory) / L"CrossDesk.exe") == Normalized(application) &&
fs::is_regular_file(HelperPath(application));
}
bool IsPrivileged() {
SID_IDENTIFIER_AUTHORITY authority = SECURITY_NT_AUTHORITY;
PSID admins = nullptr;
BOOL member = FALSE;
if (AllocateAndInitializeSid(&authority, 2, SECURITY_BUILTIN_DOMAIN_RID,
DOMAIN_ALIAS_RID_ADMINS, 0, 0, 0, 0, 0, 0, &admins)) {
CheckTokenMembership(nullptr, admins, &member);
FreeSid(admins);
}
return member != FALSE;
}
fs::path PrivateDirectory() {
PWSTR base = nullptr;
if (FAILED(SHGetKnownFolderPath(IsPrivileged() ? FOLDERID_ProgramData : FOLDERID_LocalAppData,
0, nullptr, &base))) throw std::runtime_error("Cannot locate update storage");
const fs::path directory = fs::path(base) / (L"CrossDesk-update-" + Wide(RandomId()));
CoTaskMemFree(base);
std::wstring acl = IsPrivileged() ? L"O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"
: L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)";
if (!IsPrivileged()) {
HANDLE token = nullptr;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token))
throw std::runtime_error("Cannot read update owner");
DWORD bytes = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &bytes);
std::vector<unsigned char> data(bytes);
const BOOL ok = GetTokenInformation(token, TokenUser, data.data(), bytes, &bytes);
CloseHandle(token);
if (!ok) throw std::runtime_error("Cannot read update owner");
PWSTR sid = nullptr;
if (!ConvertSidToStringSidW(reinterpret_cast<TOKEN_USER*>(data.data())->User.Sid, &sid))
throw std::runtime_error("Cannot format update owner");
acl += L"(A;OICI;FA;;;" + std::wstring(sid) + L")";
LocalFree(sid);
}
PSECURITY_DESCRIPTOR descriptor = nullptr;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(acl.c_str(), SDDL_REVISION_1,
&descriptor, nullptr)) throw std::runtime_error("Cannot protect update storage");
SECURITY_ATTRIBUTES attributes{sizeof(attributes), descriptor, FALSE};
const BOOL created = CreateDirectoryW(directory.c_str(), &attributes);
LocalFree(descriptor);
if (!created) throw std::runtime_error("Cannot create private update directory");
return directory;
}
bool OwnedJobDirectory(const fs::path& job) {
if (!IsPrivileged()) return true;
// Elevated coordinators only write to a directory owned by Administrators,
// never a caller-controlled directory with reparse points or writable ACLs.
PSID owner = nullptr;
PACL acl = nullptr;
PSECURITY_DESCRIPTOR descriptor = nullptr;
if (GetNamedSecurityInfoW(job.c_str(), SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
&owner, nullptr, &acl, nullptr, &descriptor) != ERROR_SUCCESS) return false;
bool safe = owner && IsWellKnownSid(owner, WinBuiltinAdministratorsSid) && acl;
for (DWORD i = 0; safe && i < acl->AceCount; ++i) {
void* raw = nullptr;
if (!GetAce(acl, i, &raw)) { safe = false; break; }
const auto ace = static_cast<ACCESS_ALLOWED_ACE*>(raw);
if (ace->Header.AceType != ACCESS_ALLOWED_ACE_TYPE ||
(!IsWellKnownSid(&ace->SidStart, WinBuiltinAdministratorsSid) &&
!IsWellKnownSid(&ace->SidStart, WinLocalSystemSid))) safe = false;
}
LocalFree(descriptor);
return safe;
}
void Launch(const fs::path& executable, const std::vector<std::string>& arguments) {
auto command = Quote(executable.wstring()) + L" " + Arguments(arguments);
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
const auto cwd = executable.parent_path();
if (!CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, FALSE,
CREATE_NO_WINDOW, nullptr, cwd.c_str(), &startup, &process))
throw std::runtime_error("Cannot start update process");
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
}
int Run(const fs::path& executable, const std::vector<std::string>& arguments, bool elevate) {
const auto parameters = Arguments(arguments);
const auto cwd = executable.parent_path();
SHELLEXECUTEINFOW execution{sizeof(execution)};
execution.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI;
execution.lpVerb = elevate ? L"runas" : L"open";
execution.lpFile = executable.c_str();
execution.lpParameters = parameters.c_str();
execution.lpDirectory = cwd.c_str();
execution.nShow = SW_HIDE;
if (!ShellExecuteExW(&execution) || !execution.hProcess)
throw std::runtime_error("Installation authorization was cancelled or could not start");
WaitForSingleObject(execution.hProcess, INFINITE);
DWORD code = 1;
GetExitCodeProcess(execution.hProcess, &code);
CloseHandle(execution.hProcess);
return static_cast<int>(code);
}
int RunElevated(const fs::path& executable, const fs::path& job) {
return Run(executable, {"--install", job.u8string()}, true);
}
void WaitForApplicationExit(const fs::path& application) {
StopService();
const auto expected = Normalized(application);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(45);
do {
bool running = false;
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == INVALID_HANDLE_VALUE) throw std::runtime_error("Cannot enumerate application processes");
PROCESSENTRY32W entry{sizeof(entry)};
if (Process32FirstW(snapshot, &entry)) do {
if (_wcsicmp(entry.szExeFile, application.filename().c_str()) != 0) continue;
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE,
FALSE, entry.th32ProcessID);
if (!process) { running = true; continue; }
wchar_t image[32768];
DWORD size = 32768;
if (!QueryFullProcessImageNameW(process, 0, image, &size)) running = true;
else if (Normalized(fs::path(std::wstring(image, size))) == expected &&
WaitForSingleObject(process, 0) == WAIT_TIMEOUT) running = true;
CloseHandle(process);
} while (Process32NextW(snapshot, &entry));
CloseHandle(snapshot);
if (!running) return;
std::this_thread::sleep_for(std::chrono::milliseconds(200));
} while (std::chrono::steady_clock::now() < deadline);
throw std::runtime_error("CrossDesk is still running in this or another session");
}
void InstallPackage(const fs::path& package, const fs::path& application) {
// NSIS requires /D to be the final argument, without quotes around its value.
std::wstring command = Quote(package.wstring()) + L" /S /CROSSDESK_UPDATE /D=" +
application.parent_path().wstring();
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
const auto cwd = package.parent_path();
if (!CreateProcessW(package.c_str(), command.data(), nullptr, nullptr, FALSE,
CREATE_NO_WINDOW, nullptr, cwd.c_str(), &startup, &process))
throw std::runtime_error("Cannot start CrossDesk installer");
WaitForSingleObject(process.hProcess, INFINITE);
DWORD code = 1;
GetExitCodeProcess(process.hProcess, &code);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
if (code != 0) throw std::runtime_error("CrossDesk installer failed: " + std::to_string(code));
}
void RestoreServices() {
if (!service_was_running) return;
SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT);
if (!manager) return;
SC_HANDLE service = OpenServiceW(manager, L"CrossDeskService", SERVICE_START);
if (service) { StartServiceW(service, 0, nullptr); CloseServiceHandle(service); }
CloseServiceHandle(manager);
}
void Restart(const fs::path& application) { Launch(application, {}); }
std::shared_ptr<void> PinHelper(const fs::path& helper) {
HANDLE handle = CreateFileW(helper.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (handle == INVALID_HANDLE_VALUE) throw std::runtime_error("Cannot lock update helper");
return {handle, [](void* value) { CloseHandle(value); }};
}
MaintenanceLock::MaintenanceLock(const fs::path& application) {
HANDLE mutex = CreateMutexExW(nullptr, LockName(application).c_str(), 0, SYNCHRONIZE);
const DWORD error = GetLastError();
if (!mutex || error == ERROR_ALREADY_EXISTS) {
if (mutex) CloseHandle(mutex);
throw std::runtime_error("An update is already running");
}
handle_ = reinterpret_cast<std::intptr_t>(mutex);
}
MaintenanceLock::~MaintenanceLock() {
if (handle_ != -1) CloseHandle(reinterpret_cast<HANDLE>(handle_));
}
bool MaintenanceActive(const fs::path& application) {
HANDLE mutex = OpenMutexW(SYNCHRONIZE, FALSE, LockName(application).c_str());
if (mutex) { CloseHandle(mutex); return true; }
return GetLastError() != ERROR_FILE_NOT_FOUND;
}
} // namespace crossdesk::updater