[feat] add Windows pre-login remote control and fix autostart

This commit is contained in:
dijunkun
2026-10-04 12:49:26 +08:00
parent b8f704abb9
commit 7f7b91851e
33 changed files with 1902 additions and 70 deletions
+31 -1
View File
@@ -101,6 +101,15 @@ installApp:
Call RegisterInstalledService
; Replace an existing per-user Run entry only after the new binary is in
; place. A fresh installation must not opt the user into auto-start.
ClearErrors
ExecWait '"$INSTDIR\CrossDesk.exe" --autostart-migrate' $0
${If} ${Errors}
${OrIf} $0 != 0
MessageBox MB_ICONEXCLAMATION|MB_OK "Could not migrate CrossDesk auto-start. Open CrossDesk and enable Auto Start again."
${EndIf}
; Write uninstall information
WriteUninstaller "$INSTDIR\uninstall.exe"
@@ -147,6 +156,12 @@ cancelUninstall:
Abort
uninstallApp:
ClearErrors
ExecWait '"$INSTDIR\CrossDesk.exe" --autostart-uninstall' $0
${If} ${Errors}
${OrIf} $0 != 0
MessageBox MB_ICONEXCLAMATION|MB_OK "Could not remove CrossDesk auto-start. Remove any remaining CrossDesk Autostart tasks in Task Scheduler."
${EndIf}
Call un.UnregisterInstalledService
Call un.RemoveUsbmmiddDriver
@@ -218,7 +233,18 @@ Function RegisterInstalledService
Abort
${EndIf}
DetailPrint "CrossDesk service registered for on-demand start"
; Preserve an existing unattended opt-in and resume it after upgrades.
; A fresh install still registers the ordinary on-demand lock-screen service.
SetRegView 64
ReadRegDWORD $1 HKLM "Software\CrossDesk\Unattended" "Enabled"
SetRegView 32
${If} $1 = 1
ExecWait '"$INSTDIR\CrossDesk.exe" --service-start' $0
${If} $0 != 0
MessageBox MB_ICONEXCLAMATION|MB_OK "Could not restart the unattended host. Run CrossDesk.exe --service-start from an administrator terminal."
${EndIf}
${EndIf}
DetailPrint "CrossDesk service registered"
Return
@@ -228,6 +254,10 @@ missing_service_binary:
FunctionEnd
Function un.UnregisterInstalledService
; Also disarm unattended startup when removal must fall back to sc.exe.
SetRegView 64
DeleteRegKey HKLM "Software\CrossDesk\Unattended"
SetRegView 32
IfFileExists "$INSTDIR\CrossDesk.exe" 0 unregister_with_sc
DetailPrint "Stopping CrossDesk service"
+142
View File
@@ -16,8 +16,11 @@
#ifdef _WIN32
#include <cstdio>
#include "platform/windows/autostart_task.h"
#include "platform/windows/gui/slint_backend.h"
#include "service_host.h"
#include "service_lifetime.h"
#include "unattended_config.h"
#endif
#include "config_center.h"
@@ -70,6 +73,12 @@ void PrintServiceCliUsage() {
<< " --service-ping Ping the service over named pipe IPC\n"
<< " --service-status Query service runtime status\n"
<< " --service-help Show this help\n";
std::cout
<< " --unattended-enable Enable a separate machine host before login\n"
<< " --unattended-disable Stop the machine host and disable boot "
"startup\n"
<< " --unattended-status Show machine host status and connection "
"credentials\n";
}
std::wstring GetCurrentExecutablePathW() {
@@ -97,6 +106,9 @@ bool IsServiceCliCommand(const char* arg) {
}
return std::strcmp(arg, "--service-install") == 0 ||
std::strcmp(arg, "--unattended-enable") == 0 ||
std::strcmp(arg, "--unattended-disable") == 0 ||
std::strcmp(arg, "--unattended-status") == 0 ||
std::strcmp(arg, "--service-uninstall") == 0 ||
std::strcmp(arg, "--service-start") == 0 ||
std::strcmp(arg, "--service-stop") == 0 ||
@@ -127,6 +139,97 @@ int HandleServiceCliCommand(const std::string& command) {
return 0;
}
if (command.rfind("--unattended-", 0) == 0) {
if (!crossdesk::IsAdministratorProcess()) {
std::cerr << "Run this command from an administrator terminal.\n";
return 1;
}
if (command == "--unattended-status") {
std::cout << crossdesk::ReadUnattendedStatus() << '\n';
return 0;
}
const auto service = GetSiblingServiceExecutablePath();
if (service.empty() || !std::filesystem::exists(service)) {
std::cerr << "Install CrossDesk with its Windows service first.\n";
return 1;
}
if (command == "--unattended-disable") {
// Disarm boot startup even if stopping the current process fails.
const bool saved = crossdesk::SetUnattendedEnabled(false);
const bool configured =
saved && crossdesk::InstallCrossDeskService(service.wstring());
const bool stopped = crossdesk::StopCrossDeskService(20000);
std::cout << (configured && stopped
? "Unattended host disabled.\n"
: "Failed to disable unattended host.\n");
return configured && stopped ? 0 : 1;
}
#if defined(CROSSDESK_DEBUG) || CROSSDESK_PORTABLE
std::cerr << "Unattended mode requires the installed release build.\n";
return 1;
#else
const bool was_enabled = crossdesk::IsUnattendedEnabled();
if (!crossdesk::PrepareUnattendedDirectory(true)) {
std::cerr
<< "Cannot create a private machine profile. Existing permissive "
"directories and links are rejected.\n";
return 1;
}
if (crossdesk::IsCrossDeskServiceInstalled() &&
!crossdesk::StopCrossDeskService(20000)) {
std::cerr << "Could not stop the service to configure unattended mode.\n";
return 1;
}
crossdesk::PathManager paths("CrossDesk");
crossdesk::ConfigCenter source(
(paths.GetCachePath() / "config.ini").string());
const auto machine_config = crossdesk::UnattendedDataPath() / "config.ini";
const auto staged_config =
crossdesk::UnattendedDataPath() / "config.pending.ini";
// Import only server/media preferences. Never share the GUI identity or
// consume its file-transfer paths in the SYSTEM process.
const auto host = source.IsSelfHosted() ? source.GetSignalServerHost()
: source.GetDefaultServerHost();
const int port = source.IsSelfHosted()
? source.GetSignalServerPort()
: source.GetDefaultSignalServerPort();
bool configured = std::filesystem::exists(machine_config);
if (!configured) {
crossdesk::ConfigCenter destination(staged_config.string());
configured =
destination.SetServerHost(host) == 0 &&
destination.SetServerPort(port) == 0 &&
destination.SetSelfHosted(source.IsSelfHosted()) == 0 &&
destination.SetTurnMode(source.GetTurnMode()) == 0 &&
destination.SetVideoEncodeFormat(source.GetVideoEncodeFormat()) ==
0 &&
destination.SetHardwareVideoCodec(source.IsHardwareVideoCodec()) ==
0 &&
destination.SetScreenCaptureMethod(
crossdesk::ScreenCaptureMethod::Auto) == 0 &&
destination.SetPrivacyScreen(false) == 0 &&
destination.SetSaveRemotePreviews(false) == 0;
}
if (configured && !std::filesystem::exists(machine_config)) {
configured = MoveFileExW(staged_config.c_str(), machine_config.c_str(),
MOVEFILE_WRITE_THROUGH) != FALSE;
}
if (!configured || !crossdesk::SetUnattendedEnabled(true) ||
!crossdesk::InstallCrossDeskService(service.wstring()) ||
!crossdesk::StartCrossDeskService()) {
crossdesk::SetUnattendedEnabled(was_enabled);
crossdesk::InstallCrossDeskService(service.wstring());
if (was_enabled) crossdesk::StartCrossDeskService();
std::cerr << "Failed to enable unattended host.\n";
return 1;
}
std::cout << "Unattended host enabled for boot startup.\n"
"After it connects, use --unattended-status to obtain its "
"separate device ID and password.\n";
return 0;
#endif
}
if (command == "--service-install") {
std::filesystem::path service_path = GetSiblingServiceExecutablePath();
if (service_path.empty()) {
@@ -218,6 +321,10 @@ static int RunApplication(
}
#ifdef _WIN32
if (!crossdesk::platform::MigrateLegacyAutostart("CrossDesk")) {
crossdesk::get_logger()->warn(
"Could not migrate the legacy Windows autostart entry");
}
TryStartManagedWindowsService();
#endif
@@ -288,6 +395,41 @@ int main(int argc, char* argv[]) {
}
#endif
#ifdef _WIN32
if (argc > 1 && std::strcmp(argv[1], "--unattended-host") == 0) {
// Internal service entry point: never elevate an ordinary invocation into
// a SYSTEM host, and never open the GUI or the per-user daemon here.
if (argc != 4 || !crossdesk::IsLocalSystemProcess() ||
!crossdesk::IsUnattendedEnabled())
return 1;
const std::string name(argv[2]);
if (name.rfind("Global\\CrossDeskUnattendedStop-", 0) != 0) return 1;
const std::wstring wide_name(name.begin(), name.end());
HANDLE stop = OpenEventW(SYNCHRONIZE, FALSE, wide_name.c_str());
if (!stop) return 1;
const std::string lifetime_name(argv[3]);
crossdesk::ServiceLifetimeWatcher lifetime;
if (!lifetime.Start(
std::wstring(lifetime_name.begin(), lifetime_name.end()))) {
CloseHandle(stop);
return 1;
}
crossdesk::Render render;
const int result = render.RunUnattended(stop);
CloseHandle(stop);
return result;
}
// Installer maintenance must not start the GUI or the process supervisor.
if (argc == 2 && (std::strcmp(argv[1], "--autostart-migrate") == 0 ||
std::strcmp(argv[1], "--autostart-uninstall") == 0)) {
crossdesk::PathManager paths("CrossDesk");
crossdesk::InitLogger(paths.GetLogPath().string(),
"crossdesk-autostart-cli");
const bool success =
std::strcmp(argv[1], "--autostart-migrate") == 0
? crossdesk::platform::MigrateLegacyAutostart("CrossDesk")
: crossdesk::platform::RemoveInstalledAutostart("CrossDesk");
return success ? 0 : 1;
}
if (argc == 2 &&
std::strcmp(argv[1], crossdesk::kSlintRendererProbeArgument) == 0) {
return crossdesk::RunSlintRendererProbe();
@@ -312,7 +312,16 @@ int ConfigCenter::SetSelfHosted(bool enabled) {
int ConfigCenter::SetAutostart(bool enabled) {
const bool previous = IsAutostartEnabled("CrossDesk");
if (enabled != previous && !(enabled ? EnableAutostart("CrossDesk") : DisableAutostart("CrossDesk"))) {
// Refresh the executable path and remove legacy registrations even when the
// boolean state is unchanged. In particular, a Run entry no longer counts
// as enabled for an elevated Windows build, but still needs deleting.
#ifdef _WIN32
const bool update_registration = true;
#else
const bool update_registration = enabled != previous;
#endif
if (update_registration &&
!(enabled ? EnableAutostart("CrossDesk") : DisableAutostart("CrossDesk"))) {
LOG_ERROR("SetAutostart failed");
return -1;
}
@@ -29,6 +29,9 @@ public:
~GuiApplication();
int Run();
#ifdef _WIN32
int RunUnattended(void* stop_event);
#endif
private:
struct SlintUi;
@@ -115,7 +115,7 @@ void SessionDeviceManager::RecordCaptureCadence(
}
void SessionDeviceManager::Initialize() {
InitializeAudioOutput();
if (!owner_.unattended_host_) InitializeAudioOutput();
screen_capturer_factory_ = new ScreenCapturerFactory();
device_controller_factory_ = new DeviceControllerFactory();
keyboard_capturer_ =
@@ -329,6 +329,7 @@ void SessionDeviceManager::ReleaseRemoteMouseButtons() {
#endif
void SessionDeviceManager::StartSpeakerCapturer() {
if (owner_.unattended_host_) return;
speaker_capture_.SetEnabled(true);
}
@@ -229,6 +229,13 @@ bool SettingsManager::ReadV2Locked() {
return true;
}
std::string SettingsManager::ActiveIdentity() const {
std::lock_guard<std::mutex> lock(cache_mutex_);
return owner_.config_center_->IsSelfHosted()
? cache_v2_.self_hosted_id
: cache_v2_.client_id_with_password;
}
int SettingsManager::Load() {
std::unique_lock<std::mutex> lock(cache_mutex_);
@@ -20,6 +20,7 @@ public:
int Save();
int Load();
std::string ActiveIdentity() const;
int LoadRecentConnectionAliases();
int SaveRecentConnectionAliases() const;
+5
View File
@@ -11,5 +11,10 @@ Render::Render() : application_(std::make_unique<GuiApplication>()) {}
Render::~Render() = default;
int Render::Run() { return application_->Run(); }
#ifdef _WIN32
int Render::RunUnattended(void* stop_event) {
return application_->RunUnattended(stop_event);
}
#endif
} // namespace crossdesk
+3
View File
@@ -24,6 +24,9 @@ public:
Render &operator=(const Render &) = delete;
int Run();
#ifdef _WIN32
int RunUnattended(void* stop_event);
#endif
private:
std::unique_ptr<GuiApplication> application_;
@@ -100,6 +100,8 @@ class GuiRuntime : protected gui_detail::GuiState {
#endif
PrivacyController privacy_;
// Set before initializing callbacks; immutable for the process lifetime.
bool unattended_host_ = false;
AnnouncementInbox announcements_;
PrivacySessionPolicy privacy_sessions_;
std::mutex privacy_commands_mutex_;
@@ -91,6 +91,13 @@ void PeerEventHandler::OnReceiveDataBuffer(
}
std::string source_id = std::string(src_id, src_id_size);
// The service-owned host runs as SYSTEM. Do not expose filesystem or
// clipboard operations in that security context.
if (runtime->unattended_host_ &&
(source_id == runtime->file_label_ ||
source_id == runtime->file_feedback_label_ ||
source_id == runtime->clipboard_label_))
return;
if (source_id == runtime->file_label_) {
std::string remote_user_id = std::string(user_id, user_id_size);
@@ -209,6 +216,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
return;
}
if (remote_action.type == ControlType::privacy_command) {
if (runtime->unattended_host_) return;
if (source_id == runtime->control_data_label_)
runtime->QueuePrivacyCommand(remote_id, remote_action.pc);
return;
@@ -278,8 +278,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
{
RemoteAction remote_action = MakeHostInformation(
GetHostName(), runtime->devices_.host_display_list(),
/*supports_privacy_screen=*/true, CROSSDESK_VERSION,
kClientPlatform);
/*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1,
@@ -413,8 +413,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
{
RemoteAction remote_action = MakeHostInformation(
GetHostName(), runtime->devices_.host_display_list(),
/*supports_privacy_screen=*/true, CROSSDESK_VERSION,
kClientPlatform);
/*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true;
for (std::size_t i = 0; i < remote_action.i.display_num; i++) {
LOG_INFO("Local display [{}:{}]", i + 1,
@@ -201,7 +201,7 @@ void PeerEventHandler::OnReceiveAudioBuffer(
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime) {
if (!runtime || runtime->unattended_host_) {
return;
}
+293 -40
View File
@@ -1,69 +1,322 @@
#include "platform/autostart_backend.h"
#include <windows.h>
#include <sddl.h>
#include <taskschd.h>
#include <wrl/client.h>
#include <filesystem>
#include <memory>
#include <string>
#include <vector>
#include "autostart_task.h"
#include "rd_log.h"
namespace crossdesk::platform {
namespace {
constexpr const char* kRunKey =
"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
using Microsoft::WRL::ComPtr;
using Bstr = std::unique_ptr<OLECHAR, decltype(&SysFreeString)>;
constexpr wchar_t kRunKey[] =
L"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
Bstr MakeBstr(const std::wstring& value) {
return Bstr(SysAllocString(value.c_str()), SysFreeString);
}
std::wstring FromUtf8(const std::string& value) {
if (value.empty()) return {};
const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
value.c_str(), -1, nullptr, 0);
if (size <= 0) return {};
std::wstring result(size, L'\0');
if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.c_str(), -1,
result.data(), size))
return {};
result.pop_back();
return result;
}
std::wstring CurrentUserSid() {
HANDLE token = nullptr;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return {};
DWORD size = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
std::vector<BYTE> data(size);
const bool read =
GetTokenInformation(token, TokenUser, data.data(), size, &size) != FALSE;
CloseHandle(token);
if (!read) return {};
LPWSTR sid = nullptr;
if (!ConvertSidToStringSidW(
reinterpret_cast<TOKEN_USER*>(data.data())->User.Sid, &sid))
return {};
const std::wstring result(sid);
LocalFree(sid);
return result;
}
bool Check(HRESULT result, const char* operation) {
if (SUCCEEDED(result)) return true;
LOG_ERROR("Windows autostart {} failed: HRESULT=0x{:08x}", operation,
static_cast<unsigned long>(result));
return false;
}
class ComApartment {
public:
ComApartment() : result_(CoInitializeEx(nullptr, COINIT_MULTITHREADED)) {}
~ComApartment() {
if (SUCCEEDED(result_)) CoUninitialize();
}
bool ready() const {
// Settings can run on a UI thread that already uses a different apartment.
return result_ == RPC_E_CHANGED_MODE || Check(result_, "initialize COM");
}
private:
HRESULT result_;
};
class TaskScheduler {
private:
// COM interfaces must be released before the apartment is uninitialized.
ComApartment apartment_;
ComPtr<ITaskService> service_;
public:
ComPtr<ITaskFolder> folder;
bool Open() {
if (!apartment_.ready()) return false;
if (!Check(
CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(service_.GetAddressOf())),
"create scheduler"))
return false;
const VARIANT empty{};
if (!Check(service_->Connect(empty, empty, empty, empty), "connect"))
return false;
auto root = MakeBstr(L"\\");
return root && Check(service_->GetFolder(root.get(), folder.GetAddressOf()),
"open task folder");
}
};
LONG ReadRunEntry(const std::wstring& app_name, std::wstring* command) {
DWORD size = 0;
LONG result = RegGetValueW(HKEY_CURRENT_USER, kRunKey, app_name.c_str(),
RRF_RT_REG_SZ, nullptr, nullptr, &size);
if (result != ERROR_SUCCESS) return result;
std::vector<wchar_t> buffer(size / sizeof(wchar_t) + 1, L'\0');
result = RegGetValueW(HKEY_CURRENT_USER, kRunKey, app_name.c_str(),
RRF_RT_REG_SZ, nullptr, buffer.data(), &size);
if (result == ERROR_SUCCESS) *command = buffer.data();
return result;
}
bool RemoveRunEntry(const std::wstring& app_name) {
const LONG result =
RegDeleteKeyValueW(HKEY_CURRENT_USER, kRunKey, app_name.c_str());
if (result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND ||
result == ERROR_PATH_NOT_FOUND)
return true;
LOG_ERROR("Windows autostart remove Run entry failed: error={}", result);
return false;
}
bool EnableRunEntry(const std::wstring& app_name,
const std::wstring& executable) {
HKEY key = nullptr;
const LONG opened = RegCreateKeyExW(HKEY_CURRENT_USER, kRunKey, 0, nullptr, 0,
KEY_SET_VALUE, nullptr, &key, nullptr);
if (opened != ERROR_SUCCESS) {
LOG_ERROR("Windows autostart open Run key failed: error={}", opened);
return false;
}
const std::wstring command = L"\"" + executable + L"\"";
const LONG result = RegSetValueExW(
key, app_name.c_str(), 0, REG_SZ,
reinterpret_cast<const BYTE*>(command.c_str()),
static_cast<DWORD>((command.size() + 1) * sizeof(wchar_t)));
RegCloseKey(key);
if (result != ERROR_SUCCESS)
LOG_ERROR("Windows autostart set Run entry failed: error={}", result);
return result == ERROR_SUCCESS;
}
bool RegisterLogonTask(const std::wstring& app_name,
const std::filesystem::path& executable) {
const auto sid = CurrentUserSid();
if (sid.empty()) return false;
TaskScheduler scheduler;
if (!scheduler.Open()) return false;
auto name = MakeBstr(AutostartTaskName(app_name, sid));
auto xml = MakeBstr(BuildAutostartTaskXml(
sid, executable.wstring(), executable.parent_path().wstring()));
auto user = MakeBstr(sid);
if (!name || !xml || !user) return false;
VARIANT user_id{};
user_id.vt = VT_BSTR;
user_id.bstrVal = user.get(); // Borrowed for this synchronous call.
const VARIANT empty{};
ComPtr<IRegisteredTask> task;
return Check(scheduler.folder->RegisterTask(
name.get(), xml.get(), TASK_CREATE_OR_UPDATE, user_id, empty,
TASK_LOGON_INTERACTIVE_TOKEN, empty, task.GetAddressOf()),
"register elevated logon task");
}
bool RemoveLogonTask(const std::wstring& app_name) {
const auto sid = CurrentUserSid();
if (sid.empty()) return false;
TaskScheduler scheduler;
if (!scheduler.Open()) return false;
auto name = MakeBstr(AutostartTaskName(app_name, sid));
if (!name) return false;
const HRESULT result = scheduler.folder->DeleteTask(name.get(), 0);
return result == HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND) ||
result == HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND) ||
Check(result, "delete logon task");
}
} // namespace
std::string GetAutostartExecutablePath() {
char path[32768] = {};
const DWORD length = GetModuleFileNameA(nullptr, path, sizeof(path));
return length > 0 && length < sizeof(path) ? std::string(path, length)
: std::string();
std::vector<wchar_t> path(32768);
const DWORD length =
GetModuleFileNameW(nullptr, path.data(), static_cast<DWORD>(path.size()));
if (length == 0 || length >= path.size()) return {};
return std::filesystem::path(path.data(), path.data() + length).u8string();
}
bool EnableAutostart(const std::string& app_name,
const std::string& executable_path) {
if (!std::filesystem::exists(executable_path)) return false;
HKEY key = nullptr;
if (RegOpenKeyExA(HKEY_CURRENT_USER, kRunKey, 0, KEY_WRITE, &key) !=
ERROR_SUCCESS) {
const auto name = FromUtf8(app_name);
const auto path = FromUtf8(executable_path);
if (name.empty() || path.empty()) return false;
const std::filesystem::path executable(path);
std::error_code error;
if (!executable.is_absolute() ||
!std::filesystem::is_regular_file(executable, error))
return false;
}
std::string value = executable_path;
if (value.find(' ') != std::string::npos &&
(value.front() != '"' || value.back() != '"')) {
value = "\"" + value + "\"";
}
const LONG result = RegSetValueExA(
key, app_name.c_str(), 0, REG_SZ,
reinterpret_cast<const BYTE*>(value.c_str()),
static_cast<DWORD>(value.size() + 1));
RegCloseKey(key);
return result == ERROR_SUCCESS;
if (!kAutostartRequiresElevation) return EnableRunEntry(name, path);
// Do not remove the old entry unless registering its replacement succeeded.
if (!RegisterLogonTask(name, executable)) return false;
RemoveRunEntry(name);
return true;
}
bool DisableAutostart(const std::string& app_name) {
HKEY key = nullptr;
if (RegOpenKeyExA(HKEY_CURRENT_USER, kRunKey, 0, KEY_WRITE, &key) !=
ERROR_SUCCESS) {
return false;
}
const LONG result = RegDeleteValueA(key, app_name.c_str());
RegCloseKey(key);
return result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND;
const auto name = FromUtf8(app_name);
if (name.empty()) return false;
// Portable builds must not modify an installed build's elevated task.
if (kAutostartRequiresElevation && !RemoveLogonTask(name)) return false;
return RemoveRunEntry(name);
}
bool IsAutostartEnabled(const std::string& app_name) {
HKEY key = nullptr;
if (RegOpenKeyExA(HKEY_CURRENT_USER, kRunKey, 0, KEY_READ, &key) !=
ERROR_SUCCESS) {
return false;
const auto app = FromUtf8(app_name);
if (app.empty()) return false;
if (!kAutostartRequiresElevation) {
std::wstring command;
return ReadRunEntry(app, &command) == ERROR_SUCCESS;
}
const LONG result =
RegQueryValueExA(key, app_name.c_str(), nullptr, nullptr, nullptr, nullptr);
RegCloseKey(key);
return result == ERROR_SUCCESS;
const auto sid = CurrentUserSid();
if (sid.empty()) return false;
TaskScheduler scheduler;
if (!scheduler.Open()) return false;
auto name = MakeBstr(AutostartTaskName(app, sid));
if (!name) return false;
ComPtr<IRegisteredTask> task;
if (FAILED(scheduler.folder->GetTask(name.get(), task.GetAddressOf())))
return false;
VARIANT_BOOL enabled = VARIANT_FALSE;
return SUCCEEDED(task->get_Enabled(&enabled)) && enabled == VARIANT_TRUE;
}
bool MigrateLegacyAutostart(const std::string& app_name) {
if (!kAutostartRequiresElevation) return true;
std::wstring command;
const auto name = FromUtf8(app_name);
if (name.empty()) return false;
const LONG result = ReadRunEntry(name, &command);
if (result == ERROR_FILE_NOT_FOUND || result == ERROR_PATH_NOT_FOUND)
return true;
if (result != ERROR_SUCCESS) return false;
if (command.size() >= 2 && command.front() == L'"' && command.back() == L'"')
command = command.substr(1, command.size() - 2);
const auto executable = GetAutostartExecutablePath();
if (executable.empty()) return false;
if (_wcsicmp(command.c_str(), FromUtf8(executable).c_str()) != 0) return true;
return EnableAutostart(app_name, executable);
}
bool RemoveInstalledAutostart(const std::string& app_name) {
const auto name = FromUtf8(app_name);
const auto executable = FromUtf8(GetAutostartExecutablePath());
if (name.empty() || executable.empty()) return false;
TaskScheduler scheduler;
if (!scheduler.Open()) return false;
ComPtr<IRegisteredTaskCollection> tasks;
if (!Check(scheduler.folder->GetTasks(TASK_ENUM_HIDDEN, tasks.GetAddressOf()),
"enumerate logon tasks"))
return false;
LONG count = 0;
if (!Check(tasks->get_Count(&count), "count logon tasks")) return false;
const auto prefix = name + L" Autostart ";
std::vector<std::wstring> remove;
for (LONG index = 1; index <= count; ++index) {
VARIANT item{};
item.vt = VT_I4;
item.lVal = index;
ComPtr<IRegisteredTask> task;
if (!Check(tasks->get_Item(item, task.GetAddressOf()), "read logon task"))
return false;
BSTR raw_name = nullptr;
if (!Check(task->get_Name(&raw_name), "read task name")) return false;
Bstr task_name(raw_name, SysFreeString);
const std::wstring task_text(task_name.get() ? task_name.get() : L"");
if (task_text.compare(0, prefix.size(), prefix) != 0) continue;
ComPtr<ITaskDefinition> definition;
ComPtr<IActionCollection> actions;
ComPtr<IAction> action;
ComPtr<IExecAction> exec;
LONG action_count = 0;
if (!Check(task->get_Definition(definition.GetAddressOf()),
"read task definition") ||
!Check(definition->get_Actions(actions.GetAddressOf()),
"read task actions") ||
!Check(actions->get_Count(&action_count), "count task actions"))
return false;
if (action_count != 1) continue;
if (!Check(actions->get_Item(1, action.GetAddressOf()), "read task action"))
return false;
if (FAILED(action.As(&exec))) continue;
BSTR raw_path = nullptr;
if (!Check(exec->get_Path(&raw_path), "read task executable")) return false;
Bstr path(raw_path, SysFreeString);
if (path && _wcsicmp(path.get(), executable.c_str()) == 0)
remove.push_back(task_text);
}
// Collect names first; deleting during enumeration can change its indices.
for (const auto& task : remove) {
auto task_name = MakeBstr(task);
if (!task_name || !Check(scheduler.folder->DeleteTask(task_name.get(), 0),
"remove installed logon task"))
return false;
}
std::wstring command;
const LONG result = ReadRunEntry(name, &command);
if (result == ERROR_FILE_NOT_FOUND || result == ERROR_PATH_NOT_FOUND)
return true;
if (result != ERROR_SUCCESS) return false;
if (command.size() >= 2 && command.front() == L'"' && command.back() == L'"')
command = command.substr(1, command.size() - 2);
return _wcsicmp(command.c_str(), executable.c_str()) != 0 ||
RemoveRunEntry(name);
}
} // namespace crossdesk::platform
@@ -0,0 +1,88 @@
#ifndef CROSSDESK_WINDOWS_AUTOSTART_TASK_H_
#define CROSSDESK_WINDOWS_AUTOSTART_TASK_H_
#include <string>
namespace crossdesk::platform {
// Keep this in sync with crossdesk.rc and crossdesk_portable.rc. Elevating a
// portable build manually must not change its normal login startup policy.
#if !defined(CROSSDESK_DEBUG) && \
(!defined(CROSSDESK_PORTABLE) || !CROSSDESK_PORTABLE)
inline constexpr bool kAutostartRequiresElevation = true;
#else
inline constexpr bool kAutostartRequiresElevation = false;
#endif
inline std::wstring AutostartTaskName(const std::wstring& app_name,
const std::wstring& user_sid) {
return app_name + L" Autostart " + user_sid;
}
inline std::wstring EscapeTaskXml(const std::wstring& value) {
std::wstring escaped;
for (const wchar_t character : value) {
switch (character) {
case L'&':
escaped += L"&amp;";
break;
case L'<':
escaped += L"&lt;";
break;
case L'>':
escaped += L"&gt;";
break;
case L'"':
escaped += L"&quot;";
break;
case L'\'':
escaped += L"&apos;";
break;
default:
escaped += character;
break;
}
}
return escaped;
}
inline std::wstring BuildAutostartTaskXml(const std::wstring& user_sid,
const std::wstring& executable,
const std::wstring& directory) {
const auto sid = EscapeTaskXml(user_sid);
// InteractiveToken keeps the GUI in this user's logged-in desktop and does
// not store a password. A remote desktop client must also survive battery
// operation and the scheduler's default three-day execution time limit.
return L"<Task version=\"1.3\" "
L"xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">"
L"<Triggers><LogonTrigger><Enabled>true</Enabled>"
L"<ExecutionTimeLimit>PT0S</ExecutionTimeLimit><UserId>" +
sid +
L"</UserId></LogonTrigger></Triggers>"
L"<Principals><Principal id=\"CurrentUser\"><UserId>" +
sid +
L"</UserId><LogonType>InteractiveToken</LogonType>"
L"<RunLevel>HighestAvailable</RunLevel></Principal></Principals>"
L"<Settings><MultipleInstancesPolicy>IgnoreNew</"
L"MultipleInstancesPolicy>"
L"<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>"
L"<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>"
L"<AllowHardTerminate>false</AllowHardTerminate>"
L"<Enabled>true</Enabled><ExecutionTimeLimit>PT0S</ExecutionTimeLimit>"
L"</Settings><Actions Context=\"CurrentUser\"><Exec><Command>" +
EscapeTaskXml(executable) + L"</Command><WorkingDirectory>" +
EscapeTaskXml(directory) +
L"</WorkingDirectory></Exec></Actions></Task>";
}
// Called at normal startup and by the installer after an upgrade. Only migrate
// a legacy Run entry pointing to this executable, leaving other copies alone.
bool MigrateLegacyAutostart(const std::string& app_name);
// Installer cleanup: remove tasks for this executable across users, preserving
// registrations belonging to other installation/portable directories.
bool RemoveInstalledAutostart(const std::string& app_name);
} // namespace crossdesk::platform
#endif
@@ -0,0 +1,77 @@
#include "application/gui_application.h"
#include <Windows.h>
#include <SDL3/SDL.h>
#include "rd_log.h"
#include "unattended_config.h"
namespace crossdesk {
int GuiApplication::RunUnattended(void* stop_event) {
if (!stop_event || !IsLocalSystemProcess() || !IsUnattendedEnabled() ||
!PrepareUnattendedDirectory(false))
return 1;
unattended_host_ = true;
DWORD session = 0;
if (!ProcessIdToSessionId(GetCurrentProcessId(), &session) || session == 0)
return 1;
path_manager_ = std::make_unique<PathManager>("CrossDesk");
const auto root = UnattendedDataPath();
cache_path_ = root.string();
exec_log_path_ = (root / L"logs").string();
dll_log_path_ = exec_log_path_;
InitializeLogger();
config_center_ = std::make_unique<ConfigCenter>(cache_path_ + "/config.ini");
if (config_center_->SetPrivacyScreen(false) != 0 ||
config_center_->SetSaveRemotePreviews(false) != 0)
return 1;
InitializeSettings();
// The transport and device controllers do not need a Slint window, an SDL
// video driver, a tray icon, or a logged-in user's audio/clipboard session.
if (!SDL_Init(SDL_INIT_EVENTS)) return 1;
devices_.Initialize();
ULONGLONG last_status = 0;
ULONGLONG next_peer_attempt = 0;
while (WaitForSingleObject(stop_event, 16) == WAIT_TIMEOUT) {
HandleSessionCleanup();
HandlePasswordChangeResult();
HandleCredentialRecovery();
const auto now = GetTickCount64();
if (!peer_ && now >= next_peer_attempt) {
next_peer_attempt = now + 3000;
if (CreateConnectionPeer() != 0) CloseConnectionPeer();
}
HandleConnectionStatusChange();
HandleWindowsServiceIntegration();
devices_.UpdateInteractions();
ShareLocalCursorState();
if (now - last_status >= 1000) {
last_status = now;
if (!WriteUnattendedStatus(settings_.ActiveIdentity(),
signal_connected_.load(), session)) {
LOG_WARN("Could not publish unattended host status");
}
}
// Pump native device messages without constructing an application window.
MSG message{};
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) {
TranslateMessage(&message);
DispatchMessageW(&message);
}
}
// Quiesce network callbacks before destroying the controllers they use.
CloseAllRemoteSessions();
WaitForSessionCleanup();
keyboard_.ReleaseAllRemotePressedKeys("unattended_host_exit");
devices_.DestroyDevices();
devices_.DestroyFactories();
privacy_.Shutdown();
WriteUnattendedStatus(settings_.ActiveIdentity(), false, session);
SDL_Quit();
return 0;
}
} // namespace crossdesk
@@ -20,6 +20,8 @@
#include "path_manager.h"
#include "rd_log.h"
#include "session_helper_shared.h"
#include "unattended_config.h"
#include "unattended_policy.h"
namespace crossdesk {
@@ -1007,7 +1009,7 @@ DWORD WINAPI CrossDeskServiceHost::ServiceControlHandler(
return NO_ERROR;
case SERVICE_CONTROL_STOP:
case SERVICE_CONTROL_SHUTDOWN:
instance_->ReportServiceStatus(SERVICE_STOP_PENDING, NO_ERROR, 3000);
instance_->ReportServiceStatus(SERVICE_STOP_PENDING, NO_ERROR, 20000);
instance_->RequestStop();
return NO_ERROR;
case SERVICE_CONTROL_SESSIONCHANGE: {
@@ -1054,6 +1056,28 @@ int CrossDeskServiceHost::InitializeRuntime() {
return static_cast<int>(error);
}
GUID lifetime_id{};
wchar_t lifetime_guid[40]{};
if (FAILED(CoCreateGuid(&lifetime_id)) ||
!StringFromGUID2(lifetime_id, lifetime_guid, 40))
return ERROR_GEN_FAILURE;
lifetime_mutex_name_ =
L"Global\\CrossDeskServiceLifetime-" + std::wstring(lifetime_guid);
PSECURITY_DESCRIPTOR lifetime_descriptor = nullptr;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
L"D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x00100000;;;AU)", SDDL_REVISION_1,
&lifetime_descriptor, nullptr))
return static_cast<int>(GetLastError());
SECURITY_ATTRIBUTES lifetime_attributes{sizeof(SECURITY_ATTRIBUTES),
lifetime_descriptor, FALSE};
// Owned by this service-main thread until every child has been stopped.
lifetime_mutex_ =
CreateMutexW(&lifetime_attributes, TRUE, lifetime_mutex_name_.c_str());
const DWORD lifetime_error = GetLastError();
LocalFree(lifetime_descriptor);
if (!lifetime_mutex_ || lifetime_error == ERROR_ALREADY_EXISTS)
return ERROR_INVALID_HANDLE;
started_at_tick_ = GetTickCount64();
last_sas_tick_ = 0;
active_session_id_ = WTSGetActiveConsoleSessionId();
@@ -1123,9 +1147,6 @@ int CrossDeskServiceHost::InitializeRuntime() {
}
void CrossDeskServiceHost::ShutdownRuntime() {
StopSecureInputHelper();
StopSessionHelper();
if (stop_event_ != nullptr) {
SetEvent(stop_event_);
}
@@ -1133,10 +1154,18 @@ void CrossDeskServiceHost::ShutdownRuntime() {
if (client_process_monitor_thread_.joinable()) {
client_process_monitor_thread_.join();
}
StopUnattendedHost();
if (ipc_thread_.joinable()) {
ipc_thread_.join();
}
StopSecureInputHelper();
StopSessionHelper();
if (lifetime_mutex_) {
ReleaseMutex(lifetime_mutex_);
CloseHandle(lifetime_mutex_);
lifetime_mutex_ = nullptr;
}
if (stop_event_ != nullptr) {
CloseHandle(stop_event_);
@@ -1152,6 +1181,7 @@ void CrossDeskServiceHost::RequestStop() {
void CrossDeskServiceHost::ClientProcessMonitorLoop() {
const ULONGLONG monitor_started_at = GetTickCount64();
uint64_t logoff_generation = unattended_logoff_generation_.load();
while (stop_event_ != nullptr) {
DWORD wait_result =
@@ -1163,6 +1193,34 @@ void CrossDeskServiceHost::ClientProcessMonitorLoop() {
continue;
}
const bool enabled = IsUnattendedEnabled();
const uint64_t generation = unattended_logoff_generation_.load();
if (generation != logoff_generation) {
logoff_generation = generation;
StopUnattendedHost();
// Windows may destroy/recreate Default during logoff, even when the
// numeric console session ID does not change. Rebind on the next tick.
continue;
}
if (unattended_process_ &&
WaitForSingleObject(unattended_process_, 0) != WAIT_TIMEOUT) {
StopUnattendedHost();
}
const DWORD console_session = WTSGetActiveConsoleSessionId();
const auto action =
UnattendedAction(enabled, console_session,
unattended_process_ != nullptr, unattended_session_);
if (action == UnattendedProcessAction::stop) StopUnattendedHost();
if (action == UnattendedProcessAction::start) {
if (!LaunchUnattendedHost(console_session)) {
// Boot can precede creation of WinSta0 or network availability.
WaitForSingleObject(stop_event_, 4000);
}
}
// An enabled host is independent of the GUI, including while Windows is
// between console sessions or the host is retrying startup.
if (enabled) continue;
if (GetTickCount64() - monitor_started_at <
kCrossDeskClientMonitorStartupGraceMs) {
continue;
@@ -1178,6 +1236,88 @@ void CrossDeskServiceHost::ClientProcessMonitorLoop() {
}
}
bool CrossDeskServiceHost::LaunchUnattendedHost(DWORD session_id) {
if (!IsLocalSystemProcess() || !PrepareUnattendedDirectory(false)) {
LOG_ERROR("Unattended host requires SYSTEM and a private machine profile");
return false;
}
const auto executable =
std::filesystem::path(GetCurrentExecutablePathW()).parent_path() /
L"CrossDesk.exe";
GUID nonce{};
wchar_t guid[40]{};
if (FAILED(CoCreateGuid(&nonce)) || !StringFromGUID2(nonce, guid, 40))
return false;
const std::wstring event_name =
L"Global\\CrossDeskUnattendedStop-" + std::wstring(guid);
PSECURITY_DESCRIPTOR descriptor = nullptr;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
L"D:P(A;;GA;;;SY)", SDDL_REVISION_1, &descriptor, nullptr))
return false;
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), descriptor,
FALSE};
HANDLE event = CreateEventW(&attributes, TRUE, FALSE, event_name.c_str());
const DWORD event_error = GetLastError();
LocalFree(descriptor);
if (!event || event_error == ERROR_ALREADY_EXISTS) {
if (event) CloseHandle(event);
return false;
}
HANDLE token = nullptr;
DWORD error = 0;
ScopedEnvironmentBlock environment;
PROCESS_INFORMATION process{};
STARTUPINFOW startup{};
startup.cb = sizeof(startup);
wchar_t desktop[] = L"winsta0\\default";
startup.lpDesktop = desktop;
std::wstring command = QuoteWindowsArgument(executable.wstring()) +
L" --unattended-host " +
QuoteWindowsArgument(event_name) + L" " +
QuoteWindowsArgument(lifetime_mutex_name_);
bool created = false;
if (CreateSessionSystemToken(session_id, &token, &error)) {
if (CreateEnvironmentBlock(&environment.environment, token, FALSE)) {
created = CreateProcessAsUserW(
token, executable.c_str(), command.data(), nullptr, nullptr,
FALSE, CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW,
environment.environment, executable.parent_path().c_str(),
&startup, &process) != FALSE;
}
error = created ? ERROR_SUCCESS : GetLastError();
CloseHandle(token);
}
if (process.hThread) CloseHandle(process.hThread);
if (!created) {
CloseHandle(event);
LOG_WARN("Unattended host launch failed: session={}, error={}", session_id,
error);
return false;
}
unattended_process_ = process.hProcess;
unattended_stop_event_ = event;
unattended_session_ = session_id;
LOG_INFO("Unattended host started: session={}, pid={}", session_id,
process.dwProcessId);
return true;
}
void CrossDeskServiceHost::StopUnattendedHost() {
if (unattended_stop_event_) SetEvent(unattended_stop_event_);
if (unattended_process_) WaitForSingleObject(unattended_process_, 5000);
if (unattended_process_ &&
WaitForSingleObject(unattended_process_, 0) == WAIT_TIMEOUT)
TerminateProcess(unattended_process_, ERROR_PROCESS_ABORTED);
if (unattended_process_) {
// Do not start another instance until the previous identity has exited.
WaitForSingleObject(unattended_process_, INFINITE);
CloseHandle(unattended_process_);
}
if (unattended_stop_event_) CloseHandle(unattended_stop_event_);
unattended_process_ = unattended_stop_event_ = nullptr;
unattended_session_ = 0xFFFFFFFF;
}
void CrossDeskServiceHost::ReportServiceStatus(DWORD current_state,
DWORD win32_exit_code,
DWORD wait_hint) {
@@ -1226,7 +1366,7 @@ int CrossDeskServiceHost::RunServiceLoop(bool as_service) {
if (console_mode_) {
SetConsoleCtrlHandler(&CrossDeskServiceHost::ConsoleControlHandler, TRUE);
std::cout << "CrossDesk service skeleton running in console mode. Press "
std::cout << "CrossDesk service running in console mode. Press "
"Ctrl+C to stop."
<< std::endl;
}
@@ -1234,7 +1374,7 @@ int CrossDeskServiceHost::RunServiceLoop(bool as_service) {
WaitForSingleObject(stop_event_, INFINITE);
if (as_service) {
ReportServiceStatus(SERVICE_STOP_PENDING, NO_ERROR, 3000);
ReportServiceStatus(SERVICE_STOP_PENDING, NO_ERROR, 20000);
}
ShutdownRuntime();
@@ -1448,6 +1588,9 @@ bool CrossDeskServiceHost::ShouldKeepSecureInputHelperLocked(
}
std::string CrossDeskServiceHost::ResolveInteractiveStageLocked() const {
// Session 0's input desktop cannot describe a console that has no user
// helper yet. Pre-login must always use the SYSTEM secure-desktop path.
if (prelogin_) return "credential-ui";
if (IsSasSecureDesktopGraceActiveLocked() &&
(session_helper_report_interactive_stage_.empty() ||
session_helper_report_interactive_stage_ == "user-desktop")) {
@@ -1472,6 +1615,7 @@ std::string CrossDeskServiceHost::ResolveInteractiveStageLocked() const {
std::string CrossDeskServiceHost::ResolveInteractiveDesktopLocked(
const std::string& interactive_stage) const {
if (prelogin_) return "Winlogon";
if (session_helper_status_ok_ &&
session_helper_report_input_desktop_available_ &&
!session_helper_report_input_desktop_.empty() &&
@@ -1694,10 +1838,11 @@ bool CrossDeskServiceHost::LaunchSessionHelper(DWORD session_id) {
return false;
}
std::wstring command_line = QuoteWindowsArgument(helper_path) +
L" --session-helper --session-id " +
std::to_wstring(session_id) + L" --stop-event " +
QuoteWindowsArgument(stop_event_name);
std::wstring command_line =
QuoteWindowsArgument(helper_path) + L" --session-helper --session-id " +
std::to_wstring(session_id) + L" --stop-event " +
QuoteWindowsArgument(stop_event_name) + L" --service-lifetime " +
QuoteWindowsArgument(lifetime_mutex_name_);
std::wstring mutable_command_line = command_line;
STARTUPINFOW startup_info{};
@@ -1756,6 +1901,13 @@ bool CrossDeskServiceHost::LaunchSessionHelper(DWORD session_id) {
}
}
if (!created) {
CloseHandle(stop_event_handle);
LOG_ERROR("Could not launch a supervised session helper, session={}",
session_id);
return false;
}
CloseHandle(process_info.hThread);
{
std::lock_guard<std::mutex> lock(state_mutex_);
@@ -1803,10 +1955,11 @@ bool CrossDeskServiceHost::LaunchSecureInputHelper(
return false;
}
std::wstring command_line = QuoteWindowsArgument(helper_path) +
L" --secure-input-helper --session-id " +
std::to_wstring(session_id) + L" --stop-event " +
QuoteWindowsArgument(stop_event_name);
std::wstring command_line =
QuoteWindowsArgument(helper_path) +
L" --secure-input-helper --session-id " + std::to_wstring(session_id) +
L" --stop-event " + QuoteWindowsArgument(stop_event_name) +
L" --service-lifetime " + QuoteWindowsArgument(lifetime_mutex_name_);
std::wstring mutable_command_line = command_line;
STARTUPINFOW startup_info{};
@@ -1851,6 +2004,13 @@ bool CrossDeskServiceHost::LaunchSecureInputHelper(
}
}
if (!created) {
CloseHandle(stop_event_handle);
LOG_ERROR("Could not launch a supervised session helper, session={}",
session_id);
return false;
}
CloseHandle(process_info.hThread);
{
std::lock_guard<std::mutex> lock(state_mutex_);
@@ -1985,6 +2145,10 @@ void CrossDeskServiceHost::RefreshSessionHelperReportedState() {
void CrossDeskServiceHost::RecordSessionEvent(DWORD event_type,
DWORD session_id) {
if (event_type == WTS_SESSION_LOGOFF) {
std::lock_guard<std::mutex> lock(state_mutex_);
if (session_id == active_session_id_) ++unattended_logoff_generation_;
}
{
std::lock_guard<std::mutex> lock(state_mutex_);
last_session_event_type_ = event_type;
@@ -2444,9 +2608,11 @@ bool InstallCrossDeskService(const std::wstring& binary_path) {
}
std::wstring service_command = L"\"" + binary_path + L"\" --service";
const DWORD start_type =
IsUnattendedEnabled() ? SERVICE_AUTO_START : SERVICE_DEMAND_START;
SC_HANDLE service = CreateServiceW(
manager, kCrossDeskServiceName, kCrossDeskServiceDisplayName,
SERVICE_ALL_ACCESS, SERVICE_WIN32_OWN_PROCESS, SERVICE_DEMAND_START,
SERVICE_ALL_ACCESS, SERVICE_WIN32_OWN_PROCESS, start_type,
SERVICE_ERROR_NORMAL, service_command.c_str(), nullptr, nullptr, nullptr,
nullptr, nullptr);
@@ -2460,14 +2626,14 @@ bool InstallCrossDeskService(const std::wstring& binary_path) {
service = OpenServiceW(manager, kCrossDeskServiceName,
SERVICE_CHANGE_CONFIG | SERVICE_QUERY_STATUS |
READ_CONTROL | WRITE_DAC);
SERVICE_START | READ_CONTROL | WRITE_DAC);
if (service == nullptr) {
LOG_ERROR("OpenServiceW failed, error={}", GetLastError());
CloseServiceHandle(manager);
return false;
}
if (!ChangeServiceConfigW(service, SERVICE_NO_CHANGE, SERVICE_DEMAND_START,
if (!ChangeServiceConfigW(service, SERVICE_NO_CHANGE, start_type,
SERVICE_NO_CHANGE, service_command.c_str(),
nullptr, nullptr, nullptr, nullptr, nullptr,
kCrossDeskServiceDisplayName)) {
@@ -2484,6 +2650,21 @@ bool InstallCrossDeskService(const std::wstring& binary_path) {
return false;
}
SC_ACTION actions[] = {{SC_ACTION_RESTART, 5000},
{SC_ACTION_RESTART, 15000},
{SC_ACTION_RESTART, 60000}};
SERVICE_FAILURE_ACTIONSW recovery{};
recovery.dwResetPeriod = 86400;
recovery.cActions = static_cast<DWORD>(sizeof(actions) / sizeof(actions[0]));
recovery.lpsaActions = actions;
if (!ChangeServiceConfig2W(service, SERVICE_CONFIG_FAILURE_ACTIONS,
&recovery)) {
LOG_ERROR("Could not configure service recovery, error={}", GetLastError());
CloseServiceHandle(service);
CloseServiceHandle(manager);
return false;
}
CloseServiceHandle(service);
CloseServiceHandle(manager);
return true;
@@ -2609,6 +2790,7 @@ bool StopCrossDeskService(DWORD timeout_ms) {
}
bool UninstallCrossDeskService() {
if (!SetUnattendedEnabled(false)) return false;
SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_ALL_ACCESS);
if (manager == nullptr) {
LOG_ERROR("OpenSCManagerW failed, error={}", GetLastError());
@@ -2628,7 +2810,11 @@ bool UninstallCrossDeskService() {
return false;
}
StopCrossDeskService();
if (!StopCrossDeskService()) {
CloseServiceHandle(service);
CloseServiceHandle(manager);
return false;
}
bool success = DeleteService(service) != FALSE;
if (!success) {
@@ -10,6 +10,7 @@
#include <Windows.h>
#include <cstdint>
#include <atomic>
#include <mutex>
#include <string>
#include <thread>
@@ -35,6 +36,8 @@ class CrossDeskServiceHost {
void ShutdownRuntime();
void RequestStop();
void ClientProcessMonitorLoop();
bool LaunchUnattendedHost(DWORD session_id);
void StopUnattendedHost();
void ReportServiceStatus(DWORD current_state, DWORD win32_exit_code,
DWORD wait_hint);
void IpcServerLoop();
@@ -87,7 +90,14 @@ class CrossDeskServiceHost {
HANDLE stop_event_ = nullptr;
std::thread ipc_thread_;
std::thread client_process_monitor_thread_;
// Owned exclusively by the monitor, then drained after it joins.
HANDLE unattended_process_ = nullptr;
HANDLE unattended_stop_event_ = nullptr;
DWORD unattended_session_ = 0xFFFFFFFF;
std::atomic<uint64_t> unattended_logoff_generation_{0};
std::mutex state_mutex_;
HANDLE lifetime_mutex_ = nullptr;
std::wstring lifetime_mutex_name_;
DWORD active_session_id_ = 0xFFFFFFFF;
DWORD process_session_id_ = 0xFFFFFFFF;
DWORD input_desktop_error_code_ = 0;
@@ -154,7 +164,7 @@ bool IsCrossDeskServiceInstalled();
bool InstallCrossDeskService(const std::wstring& binary_path);
bool UninstallCrossDeskService();
bool StartCrossDeskService();
bool StopCrossDeskService(DWORD timeout_ms = 5000);
bool StopCrossDeskService(DWORD timeout_ms = 20000);
std::string QueryCrossDeskService(const std::string& command,
DWORD timeout_ms = 1000);
std::string SendCrossDeskSecureDesktopKeyInput(int key_code, bool is_down,
@@ -0,0 +1,49 @@
#pragma once
#include <Windows.h>
#include <string>
#include <thread>
namespace crossdesk {
// The service's main thread owns a Global mutex for its entire lifetime.
// Unlike a process handle or a job created in Session 0, this can be waited
// on by both SYSTEM and ordinary-user helpers in another Windows session.
class ServiceLifetimeWatcher {
public:
ServiceLifetimeWatcher() = default;
ServiceLifetimeWatcher(const ServiceLifetimeWatcher&) = delete;
ServiceLifetimeWatcher& operator=(const ServiceLifetimeWatcher&) = delete;
~ServiceLifetimeWatcher() {
if (cancel_) SetEvent(cancel_);
if (thread_.joinable()) thread_.join();
if (cancel_) CloseHandle(cancel_);
if (lifetime_) CloseHandle(lifetime_);
}
bool Start(const std::wstring& name) {
if (lifetime_ || name.empty()) return false;
lifetime_ = OpenMutexW(SYNCHRONIZE, FALSE, name.c_str());
if (!lifetime_) return false;
cancel_ = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (!cancel_) return false;
thread_ = std::thread([this] {
HANDLE handles[] = {lifetime_, cancel_};
const DWORD result = WaitForMultipleObjects(2, handles, FALSE, INFINITE);
if (result != WAIT_OBJECT_0 + 1) {
// Includes abandoned ownership after a service crash. Do not wait for
// transport/capture cleanup that could otherwise leave an orphan
// online.
TerminateProcess(GetCurrentProcess(), ERROR_BROKEN_PIPE);
}
});
return true;
}
private:
HANDLE lifetime_ = nullptr;
HANDLE cancel_ = nullptr;
std::thread thread_;
};
} // namespace crossdesk
@@ -26,6 +26,7 @@
#include "path_manager.h"
#include "rd_log.h"
#include "session_helper_shared.h"
#include "service_lifetime.h"
#include "usbmmidd_policy.h"
#include "usbmmidd_virtual_display.h"
#include "windows_cursor_state.h"
@@ -1967,6 +1968,7 @@ int main(int argc, char* argv[]) {
bool run_secure_input_helper = false;
DWORD expected_session_id = 0xFFFFFFFF;
std::wstring stop_event_name;
std::wstring service_lifetime_name;
for (int index = 1; index < argc; ++index) {
std::string argument = argv[index];
if (argument == "--session-helper") {
@@ -1985,6 +1987,10 @@ int main(int argc, char* argv[]) {
stop_event_name = Utf8ToWide(argv[++index]);
continue;
}
if (argument == "--service-lifetime" && index + 1 < argc) {
service_lifetime_name = Utf8ToWide(argv[++index]);
continue;
}
}
if (!run_helper && !run_secure_input_helper) {
@@ -1993,6 +1999,9 @@ int main(int argc, char* argv[]) {
}
DWORD current_session_id = 0xFFFFFFFF;
crossdesk::ServiceLifetimeWatcher lifetime;
if (!service_lifetime_name.empty() && !lifetime.Start(service_lifetime_name))
return 1;
ProcessIdToSessionId(GetCurrentProcessId(), &current_session_id);
HANDLE stop_event = nullptr;
if (!stop_event_name.empty()) {
@@ -0,0 +1,184 @@
#include "unattended_config.h"
#include <Aclapi.h>
#include <ShlObj.h>
#include <sddl.h>
#include <ctime>
#include <fstream>
#include <nlohmann/json.hpp>
#include <vector>
namespace crossdesk {
namespace {
constexpr wchar_t kRegistryKey[] = L"SOFTWARE\\CrossDesk\\Unattended";
constexpr wchar_t kDirectorySecurity[] =
L"O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)";
bool TrustedSid(PSID sid) {
return sid && (IsWellKnownSid(sid, WinLocalSystemSid) ||
IsWellKnownSid(sid, WinBuiltinAdministratorsSid));
}
bool PrivateDirectory(const std::filesystem::path& path) {
const DWORD attributes = GetFileAttributesW(path.c_str());
if (attributes == INVALID_FILE_ATTRIBUTES ||
!(attributes & FILE_ATTRIBUTE_DIRECTORY) ||
(attributes & FILE_ATTRIBUTE_REPARSE_POINT))
return false;
PSID owner = nullptr;
PACL dacl = nullptr;
PSECURITY_DESCRIPTOR descriptor = nullptr;
if (GetNamedSecurityInfoW(
const_cast<wchar_t*>(path.c_str()), SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner,
nullptr, &dacl, nullptr, &descriptor) != ERROR_SUCCESS)
return false;
bool safe = TrustedSid(owner) && dacl && dacl->AceCount > 0;
for (DWORD index = 0; safe && index < dacl->AceCount; ++index) {
void* raw = nullptr;
if (!GetAce(dacl, index, &raw)) {
safe = false;
break;
}
auto* ace = static_cast<ACCESS_ALLOWED_ACE*>(raw);
// Only SYSTEM and elevated administrators can read credentials or write
// configuration that the service will consume as SYSTEM.
safe =
ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE &&
TrustedSid(&ace->SidStart) &&
(ace->Header.AceFlags & (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE)) ==
(OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE);
}
LocalFree(descriptor);
return safe;
}
} // namespace
std::filesystem::path UnattendedDataPath() {
PWSTR value = nullptr;
if (FAILED(SHGetKnownFolderPath(FOLDERID_ProgramData, KF_FLAG_DEFAULT,
nullptr, &value)))
return {};
const auto path = std::filesystem::path(value) / L"CrossDeskUnattended";
CoTaskMemFree(value);
return path;
}
bool IsUnattendedEnabled() {
DWORD enabled = 0;
DWORD size = sizeof(enabled);
return RegGetValueW(HKEY_LOCAL_MACHINE, kRegistryKey, L"Enabled",
RRF_RT_REG_DWORD | RRF_SUBKEY_WOW6464KEY, nullptr,
&enabled, &size) == ERROR_SUCCESS &&
enabled == 1;
}
bool SetUnattendedEnabled(bool enabled) {
if (!IsAdministratorProcess()) return false;
HKEY key = nullptr;
if (RegCreateKeyExW(HKEY_LOCAL_MACHINE, kRegistryKey, 0, nullptr, 0,
KEY_SET_VALUE | KEY_WOW64_64KEY, nullptr, &key,
nullptr) != ERROR_SUCCESS)
return false;
const DWORD value = enabled ? 1 : 0;
const auto result =
RegSetValueExW(key, L"Enabled", 0, REG_DWORD,
reinterpret_cast<const BYTE*>(&value), sizeof(value));
RegCloseKey(key);
return result == ERROR_SUCCESS;
}
bool IsAdministratorProcess() {
BYTE sid[SECURITY_MAX_SID_SIZE];
DWORD size = sizeof(sid);
BOOL member = FALSE;
return CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, sid, &size) &&
CheckTokenMembership(nullptr, sid, &member) && member;
}
bool IsLocalSystemProcess() {
HANDLE token = nullptr;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return false;
DWORD size = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
std::vector<BYTE> buffer(size);
const bool system =
GetTokenInformation(token, TokenUser, buffer.data(), size, &size) &&
IsWellKnownSid(reinterpret_cast<TOKEN_USER*>(buffer.data())->User.Sid,
WinLocalSystemSid);
CloseHandle(token);
return system;
}
bool PrepareUnattendedDirectory(bool create) {
return unattended_detail::PreparePrivateDirectory(UnattendedDataPath(),
create);
}
bool unattended_detail::PreparePrivateDirectory(
const std::filesystem::path& root, bool create) {
if (root.empty()) return false;
if (create) {
PSECURITY_DESCRIPTOR descriptor = nullptr;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
kDirectorySecurity, SDDL_REVISION_1, &descriptor, nullptr))
return false;
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), descriptor,
FALSE};
const bool created = CreateDirectoryW(root.c_str(), &attributes) != FALSE;
const DWORD error = created ? ERROR_SUCCESS : GetLastError();
LocalFree(descriptor);
if (!created && error != ERROR_ALREADY_EXISTS) return false;
}
return PrivateDirectory(root);
}
bool WriteUnattendedStatus(const std::string& identity, bool online,
DWORD session_id) {
const auto root = UnattendedDataPath();
if (root.empty()) return false;
const auto separator = identity.find('@');
nlohmann::json status = {
{"online", online},
{"process_id", GetCurrentProcessId()},
{"session_id", session_id},
{"updated_at", std::time(nullptr)},
{"device_id",
separator == std::string::npos ? "" : identity.substr(0, separator)},
{"password",
separator == std::string::npos ? "" : identity.substr(separator + 1)}};
const auto temporary = root / L"status.tmp";
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
output << status.dump(2);
output.close();
return !output.fail() &&
MoveFileExW(temporary.c_str(), (root / L"status.json").c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH);
}
std::string ReadUnattendedStatus() {
nlohmann::json status = {{"enabled", IsUnattendedEnabled()},
{"online", false}};
if (!IsAdministratorProcess() || !PrepareUnattendedDirectory(false)) {
status["error"] = "administrator_required_or_private_directory_unavailable";
return status.dump(2);
}
std::ifstream input(UnattendedDataPath() / L"status.json", std::ios::binary);
auto saved = nlohmann::json::parse(input, nullptr, false);
if (saved.is_object()) {
status.update(saved);
const auto updated = status.value("updated_at", std::time_t{0});
const auto now = std::time(nullptr);
HANDLE process =
OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE,
status.value("process_id", 0u));
const bool running =
process && WaitForSingleObject(process, 0) == WAIT_TIMEOUT;
if (process) CloseHandle(process);
status["online"] = IsUnattendedEnabled() && running && updated <= now &&
now - updated < 10 && status.value("online", false);
}
return status.dump(2);
}
} // namespace crossdesk
@@ -0,0 +1,27 @@
#pragma once
#include <Windows.h>
#include <filesystem>
#include <string>
namespace crossdesk {
// This directory is deliberately separate from every user's GUI profile.
std::filesystem::path UnattendedDataPath();
bool IsUnattendedEnabled();
bool SetUnattendedEnabled(bool enabled);
bool IsLocalSystemProcess();
bool IsAdministratorProcess();
// Reject reparse points, untrusted owners and permissive existing DACLs.
bool PrepareUnattendedDirectory(bool create);
namespace unattended_detail {
// Parameterized only for isolated native ACL tests; production always uses
// the fixed ProgramData path above.
bool PreparePrivateDirectory(const std::filesystem::path& root, bool create);
} // namespace unattended_detail
bool WriteUnattendedStatus(const std::string& identity, bool online,
DWORD session_id);
std::string ReadUnattendedStatus();
} // namespace crossdesk
@@ -0,0 +1,24 @@
#pragma once
#include <cstdint>
namespace crossdesk {
enum class UnattendedProcessAction { keep, start, stop };
// No logged-in-user condition: a console at the sign-in screen is a valid
// target. Lock/unlock and GUI presence must not interrupt an existing host.
inline UnattendedProcessAction UnattendedAction(bool enabled,
uint32_t console_session,
bool running,
uint32_t process_session) {
constexpr uint32_t unavailable = 0xFFFFFFFFu;
const bool target =
enabled && console_session != unavailable && console_session != 0;
if (running)
return target && process_session == console_session
? UnattendedProcessAction::keep
: UnattendedProcessAction::stop;
return target ? UnattendedProcessAction::start
: UnattendedProcessAction::keep;
}
} // namespace crossdesk
@@ -58,6 +58,10 @@ function main(plat, portable)
end
if plat == "windows" then
for _, name in ipairs({"autostart", "windows_autostart_test"}) do
assert(contains(effective_defines(targets[name]), "CROSSDESK_PORTABLE=1") ==
(portable == "true"), name .. " has inconsistent autostart privilege policy")
end
local resources = {
crossdesk = portable == "true" and "crossdesk_portable" or "crossdesk",
crossdesk_service = "crossdesk_service",
@@ -0,0 +1,84 @@
"""Validate generated Task Scheduler XML on macOS/Linux without changing startup.
Run: python3 apps/desktop/tests/windows_autostart_task_test.py
Native registration/migration coverage lives in windows_autostart_test.cpp.
"""
import pathlib
import shutil
import subprocess
import tempfile
import unittest
import xml.etree.ElementTree as ET
ROOT = pathlib.Path(__file__).resolve().parents[3]
NAMESPACE = {"t": "http://schemas.microsoft.com/windows/2004/02/mit/task"}
class AutostartTaskTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory(prefix="crossdesk-autostart-test-")
cls.addClassCleanup(cls.temporary.cleanup)
directory = pathlib.Path(cls.temporary.name)
source = directory / "fixture.cpp"
source.write_text(r'''
#include <iostream>
#include "platform/windows/autostart_task.h"
int main() {
std::cout << crossdesk::platform::kAutostartRequiresElevation << '\n';
const auto xml = crossdesk::platform::BuildAutostartTaskXml(
L"S-1-5-21-123-456-789-1001", L"C:\\程序 & Tools\\CrossDesk.exe",
L"C:\\程序 & Tools");
for (wchar_t character : xml) std::cout << static_cast<unsigned>(character) << ' ';
}
''', encoding="utf-8")
compiler = shutil.which("c++") or shutil.which("clang++")
if not compiler:
raise unittest.SkipTest("C++17 compiler is unavailable")
cls.variants = {}
for name, defines in {
"installed": [], "portable": ["-DCROSSDESK_PORTABLE=1"],
"debug": ["-DCROSSDESK_DEBUG"],
"portable_debug": ["-DCROSSDESK_PORTABLE=1", "-DCROSSDESK_DEBUG"],
}.items():
binary = directory / name
subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-I", str(ROOT / "apps/desktop/src"), *defines,
str(source), "-o", str(binary)], check=True)
policy, codepoints = subprocess.check_output([str(binary)], text=True).split("\n", 1)
xml = "".join(chr(int(value)) for value in codepoints.split())
cls.variants[name] = (policy, ET.fromstring(xml))
def text(self, path):
return self.variants["installed"][1].findtext(path, namespaces=NAMESPACE)
def test_privilege_policy_matches_build_type(self):
self.assertEqual({name: result[0] for name, result in self.variants.items()},
{"installed": "1", "portable": "0", "debug": "0", "portable_debug": "0"})
def test_login_targets_same_interactive_user_without_password(self):
sid = "S-1-5-21-123-456-789-1001"
self.assertEqual(self.text("t:Triggers/t:LogonTrigger/t:UserId"), sid)
self.assertEqual(self.text("t:Principals/t:Principal/t:UserId"), sid)
self.assertEqual(self.text("t:Principals/t:Principal/t:LogonType"), "InteractiveToken")
self.assertEqual(self.text("t:Principals/t:Principal/t:RunLevel"), "HighestAvailable")
self.assertEqual(self.text("t:Triggers/t:LogonTrigger/t:Enabled"), "true")
def test_command_is_not_shell_interpreted_and_unicode_round_trips(self):
self.assertEqual(self.text("t:Actions/t:Exec/t:Command"), r"C:\程序 & Tools\CrossDesk.exe")
self.assertEqual(self.text("t:Actions/t:Exec/t:WorkingDirectory"), r"C:\程序 & Tools")
self.assertIsNone(self.text("t:Actions/t:Exec/t:Arguments"))
task = self.variants["installed"][1]
self.assertEqual(task.find("t:Actions", NAMESPACE).get("Context"),
task.find("t:Principals/t:Principal", NAMESPACE).get("id"))
def test_remote_client_can_run_indefinitely_on_battery(self):
self.assertEqual(self.text("t:Triggers/t:LogonTrigger/t:ExecutionTimeLimit"), "PT0S")
self.assertEqual(self.text("t:Settings/t:DisallowStartIfOnBatteries"), "false")
self.assertEqual(self.text("t:Settings/t:StopIfGoingOnBatteries"), "false")
self.assertEqual(self.text("t:Settings/t:ExecutionTimeLimit"), "PT0S")
self.assertEqual(self.text("t:Settings/t:MultipleInstancesPolicy"), "IgnoreNew")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,278 @@
// Run from an elevated Windows terminal (the Windows CI runner is elevated).
// Uses a unique registration name and removes it before returning.
#include <windows.h>
#include <sddl.h>
#include <taskschd.h>
#include <wrl/client.h>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
#include "platform/autostart_backend.h"
#include "platform/windows/autostart_task.h"
#include "rd_log.h"
namespace {
using Microsoft::WRL::ComPtr;
namespace platform = crossdesk::platform;
constexpr wchar_t kRunKey[] =
L"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
bool Expect(bool condition, const char* message) {
if (!condition) std::cerr << "FAIL: " << message << '\n';
return condition;
}
std::wstring UserSid() {
HANDLE token = nullptr;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return {};
DWORD size = 0;
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
std::vector<BYTE> data(size);
const BOOL read =
GetTokenInformation(token, TokenUser, data.data(), size, &size);
CloseHandle(token);
if (!read) return {};
LPWSTR sid = nullptr;
if (!ConvertSidToStringSidW(
reinterpret_cast<TOKEN_USER*>(data.data())->User.Sid, &sid))
return {};
const std::wstring result(sid);
LocalFree(sid);
return result;
}
bool SetRun(const std::wstring& name, const std::wstring& path) {
HKEY key = nullptr;
if (RegCreateKeyExW(HKEY_CURRENT_USER, kRunKey, 0, nullptr, 0, KEY_SET_VALUE,
nullptr, &key, nullptr) != ERROR_SUCCESS)
return false;
const std::wstring command = L"\"" + path + L"\"";
const LONG result = RegSetValueExW(
key, name.c_str(), 0, REG_SZ,
reinterpret_cast<const BYTE*>(command.c_str()),
static_cast<DWORD>((command.size() + 1) * sizeof(wchar_t)));
RegCloseKey(key);
return result == ERROR_SUCCESS;
}
std::wstring ReadRun(const std::wstring& name) {
wchar_t value[32768] = {};
DWORD size = sizeof(value);
if (RegGetValueW(HKEY_CURRENT_USER, kRunKey, name.c_str(), RRF_RT_REG_SZ,
nullptr, value, &size) != ERROR_SUCCESS)
return {};
return value;
}
bool CheckTask(ITaskFolder* folder, BSTR task_name,
const std::filesystem::path& executable,
const std::wstring& sid) {
ComPtr<IRegisteredTask> task;
if (!Expect(SUCCEEDED(folder->GetTask(task_name, task.GetAddressOf())),
"task exists"))
return false;
ComPtr<ITaskDefinition> definition;
if (FAILED(task->get_Definition(definition.GetAddressOf()))) return false;
ComPtr<IPrincipal> principal;
if (FAILED(definition->get_Principal(principal.GetAddressOf()))) return false;
TASK_RUNLEVEL level = TASK_RUNLEVEL_LUA;
TASK_LOGON_TYPE logon = TASK_LOGON_NONE;
bool ok = Expect(SUCCEEDED(principal->get_RunLevel(&level)) &&
level == TASK_RUNLEVEL_HIGHEST,
"task requests elevation");
ok &= Expect(SUCCEEDED(principal->get_LogonType(&logon)) &&
logon == TASK_LOGON_INTERACTIVE_TOKEN,
"task uses interactive user");
BSTR user = nullptr;
ok &= Expect(SUCCEEDED(principal->get_UserId(&user)) && user && sid == user,
"task belongs to current user SID");
SysFreeString(user);
ComPtr<ITriggerCollection> triggers;
ComPtr<ITrigger> trigger;
ComPtr<ILogonTrigger> logon_trigger;
if (FAILED(definition->get_Triggers(triggers.GetAddressOf())) ||
FAILED(triggers->get_Item(1, trigger.GetAddressOf())) ||
FAILED(trigger.As(&logon_trigger)))
return false;
user = nullptr;
ok &=
Expect(SUCCEEDED(logon_trigger->get_UserId(&user)) && user && sid == user,
"logon trigger is restricted to the same user");
SysFreeString(user);
ComPtr<ITaskSettings> settings;
if (FAILED(definition->get_Settings(settings.GetAddressOf()))) return false;
VARIANT_BOOL value = VARIANT_TRUE;
ok &= Expect(SUCCEEDED(settings->get_DisallowStartIfOnBatteries(&value)) &&
value == VARIANT_FALSE,
"starts on battery");
ok &= Expect(SUCCEEDED(settings->get_StopIfGoingOnBatteries(&value)) &&
value == VARIANT_FALSE,
"survives switching to battery");
BSTR limit = nullptr;
ok &= Expect(SUCCEEDED(settings->get_ExecutionTimeLimit(&limit)) && limit &&
std::wstring(limit) == L"PT0S",
"has no three-day time limit");
SysFreeString(limit);
ComPtr<IActionCollection> actions;
ComPtr<IAction> action;
ComPtr<IExecAction> exec;
if (FAILED(definition->get_Actions(actions.GetAddressOf())) ||
FAILED(actions->get_Item(1, action.GetAddressOf())) ||
FAILED(action.As(&exec)))
return false;
BSTR path = nullptr;
ok &= Expect(
SUCCEEDED(exec->get_Path(&path)) && path && executable.wstring() == path,
"Unicode command round-trips");
SysFreeString(path);
path = nullptr;
ok &= Expect(SUCCEEDED(exec->get_WorkingDirectory(&path)) && path &&
executable.parent_path().wstring() == path,
"working directory is executable directory");
SysFreeString(path);
return ok;
}
int RunTests(ITaskFolder* folder) {
const std::string app =
"CrossDeskAutostartTest-" + std::to_string(GetCurrentProcessId());
const std::wstring name(app.begin(), app.end());
const auto sid = UserSid();
if (sid.empty()) return 1;
const auto task_name = platform::AutostartTaskName(name, sid);
BSTR task_bstr = SysAllocString(task_name.c_str());
if (!task_bstr) return 1;
const auto executable =
std::filesystem::u8path(platform::GetAutostartExecutablePath());
const auto temporary = std::filesystem::temp_directory_path() /
(name + L" \u81ea\u542f & spaces");
std::filesystem::create_directories(temporary);
const auto alternate = temporary / L"CrossDesk \u6d4b\u8bd5.exe";
// Registration never executes this file. Exercise XML/path handling without
// leaving another runnable test program in the login path.
std::ofstream(alternate) << "autostart registration test";
struct Cleanup {
ITaskFolder* folder;
BSTR task;
std::wstring name;
std::filesystem::path directory;
~Cleanup() {
folder->DeleteTask(task, 0);
SysFreeString(task);
RegDeleteKeyValueW(HKEY_CURRENT_USER, kRunKey, name.c_str());
std::error_code error;
std::filesystem::remove_all(directory, error);
}
} cleanup{folder, task_bstr, name, temporary};
bool ok = Expect(platform::DisableAutostart(app),
"disable absent entry is idempotent");
ok &= Expect(!platform::IsAutostartEnabled(app), "initially disabled");
ok &= Expect(platform::MigrateLegacyAutostart(app),
"migration without a legacy entry is a no-op");
ok &= Expect(!platform::IsAutostartEnabled(app),
"migration never opts user in");
ok &=
Expect(SetRun(name, alternate.wstring()), "seed another copy's startup");
ok &= Expect(platform::MigrateLegacyAutostart(app),
"ignore another copy during migration");
ok &= Expect(ReadRun(name) == L"\"" + alternate.wstring() + L"\"",
"another copy's registration is unchanged");
ok &= Expect(SetRun(name, executable.wstring()), "seed legacy Run entry");
ok &= Expect(
!platform::EnableAutostart(app, (temporary / L"missing.exe").u8string()),
"reject missing executable");
ok &= Expect(!ReadRun(name).empty(),
"failed registration preserves legacy entry");
ok &= Expect(platform::MigrateLegacyAutostart(app), "migrate legacy entry");
ok &= Expect(platform::IsAutostartEnabled(app), "enabled after migration");
if (platform::kAutostartRequiresElevation) {
ok &= Expect(ReadRun(name).empty(),
"remove legacy entry after successful migration");
ok &= CheckTask(folder, task_bstr, executable, sid);
} else {
ComPtr<IRegisteredTask> absent;
ok &= Expect(FAILED(folder->GetTask(task_bstr, absent.GetAddressOf())),
"portable/debug does not create elevated task");
ok &= Expect(!ReadRun(name).empty(),
"portable/debug retains ordinary startup");
}
ok &= Expect(platform::EnableAutostart(app, alternate.u8string()),
"update executable path");
ok &= Expect(platform::EnableAutostart(app, alternate.u8string()),
"enable is idempotent");
if (platform::kAutostartRequiresElevation) {
ok &= CheckTask(folder, task_bstr, alternate, sid);
ComPtr<IRegisteredTask> task;
if (SUCCEEDED(folder->GetTask(task_bstr, task.GetAddressOf()))) {
ok &= Expect(SUCCEEDED(task->put_Enabled(VARIANT_FALSE)),
"disable task externally");
ok &= Expect(!platform::IsAutostartEnabled(app),
"disabled task is not reported enabled");
ok &= Expect(platform::EnableAutostart(app, alternate.u8string()),
"reenable task");
} else {
ok = false;
}
} else {
ok &= Expect(ReadRun(name) == L"\"" + alternate.wstring() + L"\"",
"Run command quotes Unicode path with spaces");
}
ok &= Expect(platform::RemoveInstalledAutostart(app),
"uninstall skips another executable");
ok &= Expect(platform::IsAutostartEnabled(app),
"other executable keeps its startup");
ok &= Expect(platform::EnableAutostart(app, executable.u8string()),
"restore own executable");
ok &= Expect(platform::RemoveInstalledAutostart(app),
"uninstall removes own registration");
ok &= Expect(!platform::IsAutostartEnabled(app),
"uninstalled executable is no longer scheduled");
ok &= Expect(platform::EnableAutostart(app, executable.u8string()),
"reenable before disable test");
// Disabling must clean up a leftover legacy entry as well as the task.
ok &=
Expect(SetRun(name, executable.wstring()), "seed leftover legacy entry");
ok &=
Expect(platform::DisableAutostart(app), "disable existing registration");
ok &= Expect(ReadRun(name).empty() && !platform::IsAutostartEnabled(app),
"all active registrations removed");
ok &= Expect(platform::DisableAutostart(app), "disable twice succeeds");
return ok ? 0 : 1;
}
} // namespace
int main() {
crossdesk::InitLogger(std::filesystem::temp_directory_path().string(),
"crossdesk-autostart-test");
// Exercise the existing-STA case used by GUI settings as well as normal COM
// cleanup: the backend must not uninitialize this caller-owned apartment.
if (FAILED(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED))) return 1;
int result = 1;
{
ComPtr<ITaskService> service;
ComPtr<ITaskFolder> folder;
const VARIANT empty{};
BSTR root = SysAllocString(L"\\");
if (root &&
SUCCEEDED(CoCreateInstance(CLSID_TaskScheduler, nullptr,
CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(service.GetAddressOf()))) &&
SUCCEEDED(service->Connect(empty, empty, empty, empty)) &&
SUCCEEDED(service->GetFolder(root, folder.GetAddressOf()))) {
result = RunTests(folder.Get());
}
SysFreeString(root);
}
CoUninitialize();
if (result == 0) std::cout << "Windows autostart integration tests passed\n";
return result;
}
@@ -0,0 +1,84 @@
#include <Windows.h>
#include <iostream>
#include <string>
#include <thread>
#include "service_lifetime.h"
int wmain(int argc, wchar_t** argv) {
if (argc == 4 && std::wstring(argv[1]) == L"--child") {
crossdesk::ServiceLifetimeWatcher watcher;
if (!watcher.Start(argv[2])) return 2;
HANDLE ready = OpenEventW(EVENT_MODIFY_STATE, FALSE, argv[3]);
if (!ready) return 3;
SetEvent(ready);
CloseHandle(ready);
Sleep(INFINITE); // The real watcher must terminate this child.
return 4;
}
wchar_t executable[32768]{};
if (!GetModuleFileNameW(nullptr, executable, 32768)) return 1;
for (bool abandon : {false, true}) {
const auto suffix = std::to_wstring(GetCurrentProcessId()) +
(abandon ? L"-abandon" : L"-release");
const auto name = L"Global\\CrossDeskLifetimeTest-" + suffix;
const auto ready_name = name + L"-ready";
HANDLE owned = CreateEventW(nullptr, TRUE, FALSE, nullptr);
HANDLE exit_owner = CreateEventW(nullptr, TRUE, FALSE, nullptr);
HANDLE ready = CreateEventW(nullptr, TRUE, FALSE, ready_name.c_str());
if (!owned || !exit_owner || !ready) return 1;
HANDLE lifetime = nullptr;
std::thread owner([&] {
lifetime = CreateMutexW(nullptr, TRUE, name.c_str());
SetEvent(owned);
WaitForSingleObject(exit_owner, INFINITE);
if (!abandon && lifetime) ReleaseMutex(lifetime);
});
WaitForSingleObject(owned, INFINITE);
bool ok = lifetime != nullptr;
if (ok) {
// Cancellation during an ordinary helper exit must not wait for or
// release the service's ownership.
crossdesk::ServiceLifetimeWatcher cancellation;
ok = cancellation.Start(name);
}
std::wstring command = L"\"" + std::wstring(executable) + L"\" --child \"" +
name + L"\" \"" + ready_name + L"\"";
STARTUPINFOW startup{};
startup.cb = sizeof(startup);
PROCESS_INFORMATION child{};
if (ok)
ok = CreateProcessW(executable, command.data(), nullptr, nullptr, FALSE,
CREATE_NO_WINDOW, nullptr, nullptr, &startup,
&child) != FALSE;
if (ok)
ok = WaitForSingleObject(ready, 10000) == WAIT_OBJECT_0 &&
WaitForSingleObject(child.hProcess, 0) == WAIT_TIMEOUT;
SetEvent(exit_owner);
owner.join();
if (ok) ok = WaitForSingleObject(child.hProcess, 5000) == WAIT_OBJECT_0;
DWORD exit_code = 0;
if (ok)
ok = GetExitCodeProcess(child.hProcess, &exit_code) &&
exit_code == ERROR_BROKEN_PIPE;
if (child.hProcess) {
if (!ok) TerminateProcess(child.hProcess, 1);
WaitForSingleObject(child.hProcess, 5000);
CloseHandle(child.hProcess);
CloseHandle(child.hThread);
}
if (lifetime) CloseHandle(lifetime);
CloseHandle(owned);
CloseHandle(exit_owner);
CloseHandle(ready);
if (!ok) {
std::cerr << "Service lifetime test failed, abandoned=" << abandon
<< '\n';
return 1;
}
}
std::cout
<< "Service release, crash and watcher cancellation checks passed\n";
}
@@ -0,0 +1,37 @@
#include <cstdlib>
#include <iostream>
#include "platform/windows/service/unattended_policy.h"
int main() {
using crossdesk::UnattendedAction;
using Action = crossdesk::UnattendedProcessAction;
const auto expect = [](bool value, const char* message) {
if (!value) {
std::cerr << message << '\n';
std::exit(1);
}
};
constexpr uint32_t none = 0xFFFFFFFFu;
expect(UnattendedAction(false, 1, false, none) == Action::keep,
"fresh installs must not start a network host");
expect(UnattendedAction(true, none, false, none) == Action::keep,
"boot before a console exists must wait");
expect(UnattendedAction(true, 0, false, none) == Action::keep,
"never capture service session zero");
expect(UnattendedAction(true, 1, false, none) == Action::start,
"a pre-login console must start the host");
expect(UnattendedAction(true, 1, true, 1) == Action::keep,
"login, lock and GUI exit must retain the same host");
expect(UnattendedAction(true, 2, true, 1) == Action::stop,
"user switching must stop capture in the old console first");
expect(UnattendedAction(true, 2, false, none) == Action::start,
"a replacement console must start after the old host exits");
expect(UnattendedAction(true, none, true, 2) == Action::stop,
"a detached console must not keep sending frames");
expect(UnattendedAction(false, 2, true, 2) == Action::stop,
"disabling unattended mode must stop its host");
expect(UnattendedAction(true, 2, false, 2) == Action::start,
"a crashed host must restart even if its session is unchanged");
std::cout << "Unattended process lifecycle checks passed\n";
}
@@ -0,0 +1,107 @@
#include <Windows.h>
#include <Aclapi.h>
#include <sddl.h>
#include <filesystem>
#include <fstream>
#include <iostream>
#include "unattended_config.h"
namespace {
bool Check(bool condition, const char* message) {
if (!condition) std::cerr << message << ", Win32=" << GetLastError() << '\n';
return condition;
}
} // namespace
int main() {
using crossdesk::unattended_detail::PreparePrivateDirectory;
if (!Check(crossdesk::IsAdministratorProcess(), "Run this test elevated"))
return 1;
const auto root =
std::filesystem::temp_directory_path() /
(L"crossdesk-unattended-test-" + std::to_wstring(GetCurrentProcessId()));
struct Cleanup {
std::filesystem::path path;
~Cleanup() {
std::error_code error;
std::filesystem::remove_all(path, error);
}
} cleanup{root};
// Never reuse/remove a directory we did not create for this test.
if (std::filesystem::exists(root)) {
cleanup.path.clear();
return 1;
}
if (!Check(PreparePrivateDirectory(root, true), "create private profile") ||
!Check(PreparePrivateDirectory(root, true),
"idempotent profile validation"))
return 1;
const auto secret = root / L"credential.txt";
{
std::ofstream output(secret);
output << "test credential";
}
HANDLE token = nullptr, restricted = nullptr;
BYTE admin_sid[SECURITY_MAX_SID_SIZE];
DWORD sid_size = sizeof(admin_sid);
if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, admin_sid,
&sid_size) ||
!OpenProcessToken(GetCurrentProcess(),
TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY |
TOKEN_IMPERSONATE,
&token))
return 1;
SID_AND_ATTRIBUTES disabled{admin_sid, 0};
const bool restricted_ok =
CreateRestrictedToken(token, DISABLE_MAX_PRIVILEGE, 1, &disabled, 0,
nullptr, 0, nullptr, &restricted) != FALSE;
CloseHandle(token);
if (!Check(restricted_ok, "create unprivileged token")) return 1;
const bool impersonated = ImpersonateLoggedOnUser(restricted) != FALSE;
bool denied = false;
if (impersonated) {
HANDLE file = CreateFileW(secret.c_str(), GENERIC_READ, FILE_SHARE_READ,
nullptr, OPEN_EXISTING, 0, nullptr);
denied =
file == INVALID_HANDLE_VALUE && GetLastError() == ERROR_ACCESS_DENIED;
if (file != INVALID_HANDLE_VALUE) CloseHandle(file);
if (!RevertToSelf()) std::terminate();
}
CloseHandle(restricted);
if (!Check(impersonated && denied,
"ordinary token must not read credentials"))
return 1;
const auto link = root / L"link";
if (!Check(CreateSymbolicLinkW(link.c_str(), root.c_str(),
SYMBOLIC_LINK_FLAG_DIRECTORY) != FALSE,
"create directory link") ||
!Check(!PreparePrivateDirectory(link, false),
"reject directory reparse points"))
return 1;
std::filesystem::remove(link);
PSECURITY_DESCRIPTOR descriptor = nullptr;
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GR;;;WD)",
SDDL_REVISION_1, &descriptor, nullptr))
return 1;
BOOL present = FALSE, defaulted = FALSE;
PACL dacl = nullptr;
GetSecurityDescriptorDacl(descriptor, &present, &dacl, &defaulted);
const auto result = SetNamedSecurityInfoW(
const_cast<wchar_t*>(root.c_str()), SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr,
nullptr, dacl, nullptr);
LocalFree(descriptor);
if (!Check(result == ERROR_SUCCESS, "make profile permissive") ||
!Check(!PreparePrivateDirectory(root, false),
"reject exposed credentials") ||
!Check(!PreparePrivateDirectory(root, true),
"never silently adopt an unsafe profile"))
return 1;
std::cout << "Unattended profile ACL and reparse-point checks passed\n";
}
+45 -1
View File
@@ -184,6 +184,12 @@ function setup_targets()
set_default(false)
add_files("apps/desktop/tests/windows_service_mouse_ipc_test.cpp")
target("windows_unattended_policy_test")
set_kind("binary")
set_default(false)
add_includedirs("apps/desktop/src")
add_files("apps/desktop/tests/windows_unattended_policy_test.cpp")
target("windows_mouse_controller_safety_test")
set_kind("binary")
set_default(false)
@@ -396,7 +402,11 @@ function setup_targets()
add_deps("rd_log")
add_files("apps/desktop/src/autostart/*.cpp")
if is_os("windows") then
if is_config("CROSSDESK_PORTABLE", true) then
add_defines("CROSSDESK_PORTABLE=1")
end
add_files("apps/desktop/src/platform/windows/autostart.cpp")
add_syslinks("Advapi32", "Ole32", "OleAut32", "Taskschd", {public = true})
elseif is_os("macosx") then
add_files("apps/desktop/src/platform/macos/autostart.cpp")
elseif is_os("linux") then
@@ -405,6 +415,38 @@ function setup_targets()
add_includedirs("apps/desktop/src")
add_includedirs("apps/desktop/src/autostart", {public = true})
if is_os("windows") then
target("windows_unattended_config")
set_kind("object")
add_packages("nlohmann_json")
add_files("apps/desktop/src/platform/windows/service/unattended_config.cpp")
add_includedirs("apps/desktop/src/platform/windows/service")
add_syslinks("Advapi32", "Shell32", "Ole32", {public = true})
target("windows_unattended_security_test")
set_kind("binary")
set_default(false)
add_deps("windows_unattended_config")
add_includedirs("apps/desktop/src/platform/windows/service")
add_files("apps/desktop/tests/windows_unattended_security_test.cpp")
target("windows_service_lifetime_test")
set_kind("binary")
set_default(false)
add_includedirs("apps/desktop/src/platform/windows/service")
add_files("apps/desktop/tests/windows_service_lifetime_test.cpp")
target("windows_autostart_test")
set_kind("binary")
set_default(false)
add_deps("autostart", "rd_log")
add_includedirs("apps/desktop/src")
if is_config("CROSSDESK_PORTABLE", true) then
add_defines("CROSSDESK_PORTABLE=1")
end
add_files("apps/desktop/tests/windows_autostart_test.cpp")
end
target("config_center")
set_kind("object")
add_deps("rd_log", "autostart")
@@ -476,6 +518,8 @@ function setup_targets()
add_includedirs("apps/desktop/src/platform/common/input")
if is_os("windows") then
add_cxxflags("/bigobj")
add_deps("windows_unattended_config")
add_files("apps/desktop/src/platform/windows/gui/application/unattended_application.cpp")
add_links("opengl32")
add_syslinks("Comctl32", {public = true})
add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp",
@@ -535,7 +579,7 @@ function setup_targets()
target("crossdesk_service")
set_kind("binary")
add_packages("nlohmann_json")
add_deps("rd_log", "path_manager")
add_deps("rd_log", "path_manager", "windows_unattended_config")
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
add_files("apps/desktop/src/platform/windows/service/main.cpp",
"apps/desktop/src/platform/windows/service/service_host.cpp")